refactor: simplify KanbanFilters and SourceQuickFilter components

- Removed unused imports and state management for dropdowns, enhancing performance and readability.
- Replaced custom dropdown implementation with a reusable `Dropdown` component for better consistency across the UI.
- Updated button styles and logic for clearer user interaction in the filters.
- Integrated dropdowns into the `SourceQuickFilter` for improved functionality and user experience.
This commit is contained in:
Julien Froidefond
2025-10-02 08:32:10 +02:00
parent 7e79dbe49c
commit e0b5afb437
7 changed files with 668 additions and 204 deletions

View File

@@ -1,9 +1,8 @@
'use client'; 'use client';
import { useState, useEffect, useRef } from 'react'; import { useState } from 'react';
import { createPortal } from 'react-dom';
import { TaskPriority, TaskStatus } from '@/lib/types'; import { TaskPriority, TaskStatus } from '@/lib/types';
import { Button, SearchInput, ToggleButton, ControlPanel, ControlSection, ControlGroup, FilterSummary } from '@/components/ui'; import { SearchInput, ToggleButton, ControlPanel, ControlSection, ControlGroup, FilterSummary, Dropdown } from '@/components/ui';
import { useTasksContext } from '@/contexts/TasksContext'; import { useTasksContext } from '@/contexts/TasksContext';
import { SORT_OPTIONS } from '@/lib/sort-config'; import { SORT_OPTIONS } from '@/lib/sort-config';
import { useUserPreferences } from '@/contexts/UserPreferencesContext'; import { useUserPreferences } from '@/contexts/UserPreferencesContext';
@@ -31,25 +30,8 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
// Utiliser les props si disponibles, sinon utiliser le context // Utiliser les props si disponibles, sinon utiliser le context
const hiddenStatuses = propsHiddenStatuses || new Set(preferences.columnVisibility.hiddenStatuses); const hiddenStatuses = propsHiddenStatuses || new Set(preferences.columnVisibility.hiddenStatuses);
const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility; const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility;
const [isSortExpanded, setIsSortExpanded] = useState(false); const [isSortOpen, setIsSortOpen] = useState(false);
const isMobile = useIsMobile(768); // Tailwind md breakpoint const isMobile = useIsMobile(768); // Tailwind md breakpoint
const sortDropdownRef = useRef<HTMLDivElement>(null);
const sortButtonRef = useRef<HTMLButtonElement>(null);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
// Fermer les dropdowns en cliquant à l'extérieur
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (sortDropdownRef.current && !sortDropdownRef.current.contains(event.target as Node)) {
setIsSortExpanded(false);
}
}
if (isSortExpanded) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [isSortExpanded]);
// Handler pour la recherche avec debounce intégré // Handler pour la recherche avec debounce intégré
const handleSearchChange = (search: string) => { const handleSearchChange = (search: string) => {
@@ -115,16 +97,6 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
}); });
}; };
const handleSortToggle = () => {
if (!isSortExpanded && sortButtonRef.current) {
const rect = sortButtonRef.current.getBoundingClientRect();
setDropdownPosition({
top: rect.bottom + window.scrollY + 4,
left: rect.left + window.scrollX
});
}
setIsSortExpanded(!isSortExpanded);
};
const handleClearFilters = () => { const handleClearFilters = () => {
@@ -194,33 +166,31 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
{/* Bouton de tri */} {/* Bouton de tri */}
<div className="relative" ref={sortDropdownRef}> <Dropdown
<Button open={isSortOpen}
ref={sortButtonRef} onOpenChange={setIsSortOpen}
variant="ghost" trigger="☰ Tris"
onClick={handleSortToggle} content={
className="flex items-center gap-2" <div className="max-h-64 overflow-y-auto">
{SORT_OPTIONS.map((option) => (
<button
key={option.key}
onClick={() => handleSortChange(option.key)}
className={`w-full px-3 py-2 text-left text-xs font-mono hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 ${
(filters.sortBy || 'priority-desc') === option.key
? 'bg-cyan-600/20 text-cyan-400 border-l-2 border-cyan-400'
: 'text-[var(--muted-foreground)]'
}`}
> >
<svg <span className="text-base">{option.icon}</span>
className="w-4 h-4" <span>{option.label}</span>
fill="none" </button>
stroke="currentColor" ))}
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4h13M3 8h9m-9 4h6m4 0l4-4m0 0l4 4m-4-4v12" />
</svg>
Tris
<svg
className={`w-4 h-4 transition-transform ${isSortExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</Button>
</div> </div>
}
placement="bottom-start"
className="w-80"
/>
</ControlSection> </ControlSection>
@@ -283,42 +253,6 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
</div> </div>
{/* Dropdown de tri rendu via portail pour éviter les problèmes de z-index */}
{isSortExpanded && typeof window !== 'undefined' && createPortal(
<div
ref={sortDropdownRef}
className="fixed w-80 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-xl z-[9999] max-h-64 overflow-y-auto"
style={{
top: dropdownPosition.top,
left: dropdownPosition.left
}}
>
{SORT_OPTIONS.map((option) => (
<button
key={option.key}
onClick={() => {
handleSortChange(option.key);
setIsSortExpanded(false);
}}
className={`w-full px-3 py-2 text-left text-xs font-mono hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2 ${
(filters.sortBy || 'priority-desc') === option.key
? 'bg-cyan-600/20 text-cyan-400 border-l-2 border-cyan-400'
: 'text-[var(--muted-foreground)]'
}`}
>
<span className="text-base">{option.icon}</span>
<span className="flex-1">{option.label}</span>
{(filters.sortBy || 'priority-desc') === option.key && (
<svg className="w-4 h-4 text-cyan-400" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
)}
</button>
))}
</div>,
document.body
)}
</ControlPanel> </ControlPanel>
); );
} }

View File

@@ -1,7 +1,8 @@
'use client'; 'use client';
import { useState, useMemo, useRef, useEffect } from 'react'; import { useState, useMemo } from 'react';
import { useTasksContext } from '@/contexts/TasksContext'; import { useTasksContext } from '@/contexts/TasksContext';
import { Dropdown, Button } from '@/components/ui';
import type { KanbanFilters } from '@/lib/types'; import type { KanbanFilters } from '@/lib/types';
interface SourceQuickFilterProps { interface SourceQuickFilterProps {
@@ -21,7 +22,6 @@ type FilterMode = 'all' | 'show' | 'hide';
export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilterProps) { export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilterProps) {
const { regularTasks } = useTasksContext(); const { regularTasks } = useTasksContext();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Vérifier quelles sources ont des tâches // Vérifier quelles sources ont des tâches
const sources = useMemo((): SourceOption[] => { const sources = useMemo((): SourceOption[] => {
@@ -44,17 +44,6 @@ export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilte
].filter(source => source.hasTasks); ].filter(source => source.hasTasks);
}, [regularTasks]); }, [regularTasks]);
// Fermer le dropdown quand on clique ailleurs
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Si aucune source disponible, on n'affiche rien // Si aucune source disponible, on n'affiche rien
if (sources.length === 0) { if (sources.length === 0) {
@@ -85,6 +74,15 @@ export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilte
}; };
// Déterminer le texte du bouton principal // Déterminer le texte du bouton principal
const getMainButtonVariant = () => {
const activeFilters = sources.filter(source => {
const mode = getSourceMode(source.id);
return mode !== 'all';
});
return activeFilters.length === 0 ? 'secondary' : 'selected';
};
const getMainButtonText = () => { const getMainButtonText = () => {
const activeFilters = sources.filter(source => { const activeFilters = sources.filter(source => {
const mode = getSourceMode(source.id); const mode = getSourceMode(source.id);
@@ -102,43 +100,8 @@ export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilte
} }
}; };
const getMainButtonStyle = () => { const dropdownContent = (
const activeFilters = sources.filter(source => { <div className="space-y-3">
const mode = getSourceMode(source.id);
return mode !== 'all';
});
if (activeFilters.length === 0) {
return 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50';
} else {
return 'bg-[var(--primary)]/20 text-[var(--primary)] border border-[var(--primary)]/30';
}
};
return (
<div className="relative" ref={dropdownRef}>
{/* Bouton principal */}
<button
onClick={() => setIsOpen(!isOpen)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-mono transition-all ${getMainButtonStyle()}`}
title="Filtrer par source"
>
<span>🔌</span>
{getMainButtonText()}
<svg
className={`w-4 h-4 transition-transform ${isOpen ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{/* Dropdown */}
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-md shadow-lg z-50 min-w-[240px]">
<div className="p-3 space-y-3">
{sources.map((source) => { {sources.map((source) => {
const currentMode = getSourceMode(source.id); const currentMode = getSourceMode(source.id);
@@ -179,7 +142,9 @@ export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilte
{/* Option pour réinitialiser tous les filtres */} {/* Option pour réinitialiser tous les filtres */}
<div className="border-t border-[var(--border)] pt-2 mt-2"> <div className="border-t border-[var(--border)] pt-2 mt-2">
<button <Button
variant="secondary"
size="sm"
onClick={() => { onClick={() => {
const updates: Partial<KanbanFilters> = { const updates: Partial<KanbanFilters> = {
showJiraOnly: false, showJiraOnly: false,
@@ -188,19 +153,27 @@ export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilte
hideTfsTasks: false hideTfsTasks: false
}; };
onFiltersChange({ ...filters, ...updates }); onFiltersChange({ ...filters, ...updates });
setIsOpen(false);
}} }}
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm font-mono transition-all text-left bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50 hover:text-[var(--foreground)]" className="w-full justify-start font-mono"
title="Réinitialiser tous les filtres de source" title="Réinitialiser tous les filtres de source"
> >
<span>🔄</span> <span>🔄</span>
<span className="flex-1">Réinitialiser tout</span> <span className="flex-1">Réinitialiser tout</span>
</button> </Button>
</div> </div>
</div> </div>
</div> );
)}
</div> return (
<Dropdown
open={isOpen}
onOpenChange={setIsOpen}
trigger={`🔌 ${getMainButtonText()}`}
variant={getMainButtonVariant()}
content={dropdownContent}
placement="bottom-start"
className="min-w-[240px]"
/>
); );
} }

View File

@@ -9,7 +9,8 @@ import {
FormsSection, FormsSection,
NavigationSection, NavigationSection,
FeedbackSection, FeedbackSection,
DataDisplaySection DataDisplaySection,
DropdownsSection
} from './sections'; } from './sections';
export function UIShowcaseClient() { export function UIShowcaseClient() {
@@ -33,6 +34,7 @@ export function UIShowcaseClient() {
<ButtonsSection /> <ButtonsSection />
<BadgesSection /> <BadgesSection />
<CardsSection /> <CardsSection />
<DropdownsSection />
<FormsSection /> <FormsSection />
<NavigationSection /> <NavigationSection />
<FeedbackSection /> <FeedbackSection />

View File

@@ -0,0 +1,257 @@
'use client';
import { useState } from 'react';
import { Dropdown } from '@/components/ui';
import type { DropdownPlacement, DropdownVariant } from '@/components/ui';
const PLACEMENTS: DropdownPlacement[] = [
'top-start', 'top', 'top-end',
'left-start', 'left', 'left-end',
'right-start', 'right', 'right-end',
'bottom-start', 'bottom', 'bottom-end'
];
const SAMPLE_OPTIONS = [
{ id: '1', label: 'Option 1', icon: '🔹' },
{ id: '2', label: 'Option 2', icon: '🔷' },
{ id: '3', label: 'Option 3', icon: '🔸' },
{ id: '4', label: 'Option 4', icon: '🔶' },
];
const VARIANTS: DropdownVariant[] = [
'default', 'secondary', 'primary', 'selected', 'ghost'
];
export function DropdownsSection() {
const [selectedOption, setSelectedOption] = useState<string>('');
return (
<section id="dropdowns" className="space-y-8">
<div>
<h2 className="text-3xl font-bold text-[var(--foreground)] mb-2">Dropdowns</h2>
<p className="text-[var(--muted-foreground)]">
Composants dropdown avec portal automatique et positionnement intelligent
</p>
</div>
<div className="space-y-8">
{/* Dropdown basique */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-[var(--foreground)]">Dropdown Basique</h3>
<div className="flex gap-4">
<Dropdown
trigger="Menu simple"
variant="secondary"
content={
<div className="space-y-1">
{SAMPLE_OPTIONS.map(option => (
<button
key={option.id}
onClick={() => setSelectedOption(option.label)}
className="w-full px-3 py-2 text-left text-sm hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2"
>
<span>{option.icon}</span>
<span>{option.label}</span>
</button>
))}
</div>
}
placement="bottom-start"
/>
{selectedOption && (
<div className="text-sm text-[var(--muted-foreground)]">
Sélectionné: {selectedOption}
</div>
)}
</div>
</div>
{/* Dropdown avec état contrôlé */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-[var(--foreground)]">Dropdown avec État Contrôlé</h3>
<div className="flex gap-4">
<Dropdown
trigger="Menu contrôlé"
variant="primary"
content={
<div className="space-y-1">
<div className="px-3 py-2 text-xs font-mono text-[var(--muted-foreground)] border-b border-[var(--border)]">
Menu avec état contrôlé
</div>
{SAMPLE_OPTIONS.map(option => (
<button
key={option.id}
onClick={() => setSelectedOption(option.label)}
className="w-full px-3 py-2 text-left text-sm hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2"
>
<span>{option.icon}</span>
<span>{option.label}</span>
</button>
))}
</div>
}
placement="bottom-start"
/>
</div>
</div>
{/* Dropdown avec différents placements */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-[var(--foreground)]">Placements</h3>
<div className="grid grid-cols-3 gap-4">
{PLACEMENTS.map(placement => (
<Dropdown
key={placement}
trigger={placement}
variant="ghost"
content={
<div className="space-y-1 min-w-[120px]">
<div className="px-3 py-2 text-xs font-mono text-[var(--muted-foreground)] border-b border-[var(--border)]">
{placement}
</div>
{SAMPLE_OPTIONS.slice(0, 2).map(option => (
<button
key={option.id}
className="w-full px-3 py-2 text-left text-sm hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2"
>
<span>{option.icon}</span>
<span>{option.label}</span>
</button>
))}
</div>
}
placement={placement}
/>
))}
</div>
</div>
{/* Dropdown avec différents variants */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-[var(--foreground)]">Variants</h3>
<div className="flex gap-4 flex-wrap">
{VARIANTS.map(variant => (
<Dropdown
key={variant}
trigger={variant}
variant={variant}
content={
<div className="space-y-1 min-w-[120px]">
<div className="px-3 py-2 text-xs font-mono text-[var(--muted-foreground)] border-b border-[var(--border)]">
{variant}
</div>
{SAMPLE_OPTIONS.slice(0, 2).map(option => (
<button
key={option.id}
className="w-full px-3 py-2 text-left text-sm hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2"
>
<span>{option.icon}</span>
<span>{option.label}</span>
</button>
))}
</div>
}
placement="bottom-start"
/>
))}
</div>
</div>
{/* Dropdown avec contenu complexe */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-[var(--foreground)]">Contenu Complexe</h3>
<div className="flex gap-4">
<Dropdown
trigger="Menu complexe"
variant="selected"
content={
<div className="w-80">
<div className="px-4 py-3 border-b border-[var(--border)]">
<h4 className="font-semibold text-[var(--foreground)]">Paramètres</h4>
<p className="text-sm text-[var(--muted-foreground)]">Configurez vos préférences</p>
</div>
<div className="p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm">Notifications</span>
<input type="checkbox" className="w-4 h-4" defaultChecked />
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Mode sombre</span>
<input type="checkbox" className="w-4 h-4" />
</div>
<div className="flex items-center justify-between">
<span className="text-sm">Langue</span>
<select className="text-sm bg-[var(--background)] border border-[var(--border)] rounded px-2 py-1">
<option>Français</option>
<option>English</option>
</select>
</div>
</div>
<div className="px-4 py-3 border-t border-[var(--border)] flex gap-2">
<button className="flex-1 px-3 py-2 text-sm bg-[var(--primary)] text-[var(--primary-foreground)] rounded-md hover:bg-[var(--primary)]/90 transition-colors">
Sauvegarder
</button>
<button className="flex-1 px-3 py-2 text-sm bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] rounded-md hover:bg-[var(--card-hover)] transition-colors">
Annuler
</button>
</div>
</div>
}
placement="bottom-start"
/>
</div>
</div>
{/* Dropdown avec désactivation */}
<div className="space-y-4">
<h3 className="text-xl font-semibold text-[var(--foreground)]">États</h3>
<div className="flex gap-4">
<Dropdown
trigger="Normal"
variant="secondary"
content={
<div className="space-y-1">
{SAMPLE_OPTIONS.map(option => (
<button
key={option.id}
className="w-full px-3 py-2 text-left text-sm hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2"
>
<span>{option.icon}</span>
<span>{option.label}</span>
</button>
))}
</div>
}
placement="bottom-start"
/>
<Dropdown
trigger="Désactivé"
variant="secondary"
disabled={true}
content={
<div className="space-y-1">
{SAMPLE_OPTIONS.map(option => (
<button
key={option.id}
className="w-full px-3 py-2 text-left text-sm hover:bg-[var(--card-hover)] transition-colors flex items-center gap-2"
>
<span>{option.icon}</span>
<span>{option.label}</span>
</button>
))}
</div>
}
placement="bottom-start"
/>
</div>
</div>
</div>
</section>
);
}

View File

@@ -5,3 +5,4 @@ export { FormsSection } from './FormsSection';
export { NavigationSection } from './NavigationSection'; export { NavigationSection } from './NavigationSection';
export { FeedbackSection } from './FeedbackSection'; export { FeedbackSection } from './FeedbackSection';
export { DataDisplaySection } from './DataDisplaySection'; export { DataDisplaySection } from './DataDisplaySection';
export { DropdownsSection } from './DropdownsSection';

View File

@@ -0,0 +1,295 @@
'use client';
import { useState, useRef, useEffect, useCallback, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { cn } from '@/lib/utils';
export type DropdownPlacement =
| 'top-start'
| 'top'
| 'top-end'
| 'bottom-start'
| 'bottom'
| 'bottom-end'
| 'left-start'
| 'left'
| 'left-end'
| 'right-start'
| 'right'
| 'right-end';
export type DropdownVariant =
| 'default'
| 'secondary'
| 'primary'
| 'selected'
| 'ghost'
| 'custom';
interface DropdownProps {
/** Texte du bouton déclencheur */
trigger: string;
/** Variant du bouton trigger */
variant?: DropdownVariant;
/** Contenu du dropdown */
content: ReactNode;
/** Position du dropdown par rapport au trigger */
placement?: DropdownPlacement;
/** Z-index du dropdown */
zIndex?: number;
/** Classe CSS additionnelle pour le dropdown */
className?: string;
/** Classe CSS additionnelle pour le contenu */
contentClassName?: string;
/** Callback quand le dropdown s'ouvre */
onOpen?: () => void;
/** Callback quand le dropdown se ferme */
onClose?: () => void;
/** Contrôle externe de l'état ouvert/fermé */
open?: boolean;
/** Callback quand l'état change */
onOpenChange?: (open: boolean) => void;
/** Fermer quand on clique à l'extérieur */
closeOnOutsideClick?: boolean;
/** Fermer quand on appuie sur Escape */
closeOnEscape?: boolean;
/** Désactiver le dropdown */
disabled?: boolean;
}
export function Dropdown({
trigger,
variant = 'default',
content,
placement = 'bottom-start',
zIndex = 9999,
className,
contentClassName,
onOpen,
onClose,
open: controlledOpen,
onOpenChange,
closeOnOutsideClick = true,
closeOnEscape = true,
disabled = false
}: DropdownProps) {
const [isOpen] = useState(false);
const [mounted, setMounted] = useState(false);
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
const [positionCalculated, setPositionCalculated] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
// Utiliser l'état contrôlé si fourni, sinon utiliser l'état interne
const open = controlledOpen !== undefined ? controlledOpen : isOpen;
// Mount check pour SSR
useEffect(() => {
setMounted(true);
}, []);
const handleOpen = () => {
if (disabled) return;
// Toujours appeler onOpenChange, même en mode contrôlé
onOpenChange?.(true);
onOpen?.();
};
const handleClose = useCallback(() => {
// Toujours appeler onOpenChange, même en mode contrôlé
onOpenChange?.(false);
onClose?.();
}, [onOpenChange, onClose]);
// Calculer la position du dropdown
const calculatePosition = useCallback(() => {
if (!triggerRef.current) return;
const rect = triggerRef.current.getBoundingClientRect();
let top = 0;
let left = 0;
// Calculer la position selon le placement
switch (placement) {
case 'top-start':
top = rect.top - 4;
left = rect.left;
break;
case 'top':
top = rect.top - 4;
left = rect.left + rect.width / 2;
break;
case 'top-end':
top = rect.top - 4;
left = rect.right;
break;
case 'bottom-start':
top = rect.bottom + 4;
left = rect.left;
break;
case 'bottom':
top = rect.bottom + 4;
left = rect.left + rect.width / 2;
break;
case 'bottom-end':
top = rect.bottom + 4;
left = rect.right;
break;
case 'left-start':
top = rect.top;
left = rect.left - 4;
break;
case 'left':
top = rect.top + rect.height / 2;
left = rect.left - 4;
break;
case 'left-end':
top = rect.bottom;
left = rect.left - 4;
break;
case 'right-start':
top = rect.top;
left = rect.right + 4;
break;
case 'right':
top = rect.top + rect.height / 2;
left = rect.right + 4;
break;
case 'right-end':
top = rect.bottom;
left = rect.right + 4;
break;
}
setDropdownPosition({ top, left });
setPositionCalculated(true);
}, [placement]);
// Mettre à jour la position quand le dropdown s'ouvre
useEffect(() => {
if (open) {
setPositionCalculated(false);
calculatePosition();
} else {
setPositionCalculated(false);
}
}, [open, calculatePosition]);
// Gérer les événements clavier
useEffect(() => {
if (!open || !closeOnEscape) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
handleClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [open, closeOnEscape, handleClose]);
// Gérer les clics à l'extérieur
useEffect(() => {
if (!open || !closeOnOutsideClick) return;
const handleClickOutside = (event: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
handleClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, [open, closeOnOutsideClick, handleClose]);
const handleToggle = () => {
if (disabled) return;
if (open) {
handleClose();
} else {
handleOpen();
}
};
// Styles des variants
const getVariantStyles = () => {
switch (variant) {
case 'primary':
return 'bg-[color-mix(in_srgb,var(--primary)_15%,transparent)] text-[var(--foreground)] border border-[var(--primary)] hover:bg-[color-mix(in_srgb,var(--primary)_20%,transparent)]';
case 'secondary':
return 'bg-[var(--card)] text-[var(--muted-foreground)] border border-[var(--border)] hover:border-[var(--primary)]/50';
case 'selected':
return 'bg-[color-mix(in_srgb,var(--primary)_15%,transparent)] text-[var(--foreground)] border border-[var(--primary)] hover:bg-[color-mix(in_srgb,var(--primary)_20%,transparent)]';
case 'ghost':
return 'text-[var(--foreground)] hover:bg-[var(--card-hover)]';
case 'custom':
return ''; // Pas de styles par défaut pour custom
default:
return 'bg-[var(--card)] text-[var(--foreground)] border border-[var(--border)] hover:bg-[var(--card-hover)]';
}
};
// Créer le bouton trigger
const triggerElement = (
<button
ref={triggerRef}
onClick={(e: React.MouseEvent) => {
e.stopPropagation(); // Empêcher la propagation vers closeOnOutsideClick
handleToggle();
}}
onMouseDown={(e: React.MouseEvent) => {
e.stopPropagation(); // Empêcher la propagation vers closeOnOutsideClick
}}
aria-expanded={open}
aria-haspopup={true}
disabled={disabled}
className={cn(
'flex items-center gap-2 px-3 py-2 text-sm font-medium rounded-md transition-colors',
getVariantStyles(),
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{trigger}
<svg
className={`w-4 h-4 transition-transform ${open ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
);
// Contenu du dropdown
const dropdownContent = open && mounted && positionCalculated && (
<div
ref={dropdownRef}
className={cn(
'fixed bg-[var(--card)] border border-[var(--border)] rounded-md shadow-lg',
className
)}
style={{
top: `${dropdownPosition.top}px`,
left: `${dropdownPosition.left}px`,
zIndex
}}
>
<div className={cn('p-3', contentClassName)}>
{content}
</div>
</div>
);
return (
<>
{triggerElement}
{mounted && createPortal(dropdownContent, document.body)}
</>
);
}

View File

@@ -3,6 +3,7 @@ export { Button } from './Button';
export { Badge } from './Badge'; export { Badge } from './Badge';
export { Alert, AlertTitle, AlertDescription } from './Alert'; export { Alert, AlertTitle, AlertDescription } from './Alert';
export { Input } from './Input'; export { Input } from './Input';
export { Card, CardHeader, CardTitle, CardContent, CardFooter } from './Card';
export { StyledCard } from './StyledCard'; export { StyledCard } from './StyledCard';
// Composants Dashboard // Composants Dashboard
@@ -43,7 +44,8 @@ export { SkeletonCard, SkeletonGrid } from './SkeletonCard';
export { MetricsGrid } from './MetricsGrid'; export { MetricsGrid } from './MetricsGrid';
// Composants existants // Composants existants
export { Card, CardHeader, CardTitle, CardContent, CardFooter } from './Card'; export { Dropdown } from './Dropdown';
export type { DropdownPlacement, DropdownVariant } from './Dropdown';
export { FontSizeToggle } from './FontSizeToggle'; export { FontSizeToggle } from './FontSizeToggle';
export { Modal } from './Modal'; export { Modal } from './Modal';
export { ConfirmModal } from './ConfirmModal'; export { ConfirmModal } from './ConfirmModal';