diff --git a/src/components/kanban/KanbanFilters.tsx b/src/components/kanban/KanbanFilters.tsx index 94f7c53..d6fb3fd 100644 --- a/src/components/kanban/KanbanFilters.tsx +++ b/src/components/kanban/KanbanFilters.tsx @@ -1,9 +1,8 @@ 'use client'; -import { useState, useEffect, useRef } from 'react'; -import { createPortal } from 'react-dom'; +import { useState } from 'react'; 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 { SORT_OPTIONS } from '@/lib/sort-config'; 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 const hiddenStatuses = propsHiddenStatuses || new Set(preferences.columnVisibility.hiddenStatuses); const toggleStatusVisibility = onToggleStatusVisibility || toggleColumnVisibility; - const [isSortExpanded, setIsSortExpanded] = useState(false); + const [isSortOpen, setIsSortOpen] = useState(false); const isMobile = useIsMobile(768); // Tailwind md breakpoint - const sortDropdownRef = useRef(null); - const sortButtonRef = useRef(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é 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 = () => { @@ -194,33 +166,31 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH {/* Bouton de tri */} -
- - -
+ + {SORT_OPTIONS.map((option) => ( + + ))} + + } + placement="bottom-start" + className="w-80" + /> @@ -282,42 +252,6 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH - - {/* Dropdown de tri rendu via portail pour éviter les problèmes de z-index */} - {isSortExpanded && typeof window !== 'undefined' && createPortal( -
- {SORT_OPTIONS.map((option) => ( - - ))} -
, - document.body - )} ); diff --git a/src/components/kanban/SourceQuickFilter.tsx b/src/components/kanban/SourceQuickFilter.tsx index 0b895eb..7582990 100644 --- a/src/components/kanban/SourceQuickFilter.tsx +++ b/src/components/kanban/SourceQuickFilter.tsx @@ -1,7 +1,8 @@ 'use client'; -import { useState, useMemo, useRef, useEffect } from 'react'; +import { useState, useMemo } from 'react'; import { useTasksContext } from '@/contexts/TasksContext'; +import { Dropdown, Button } from '@/components/ui'; import type { KanbanFilters } from '@/lib/types'; interface SourceQuickFilterProps { @@ -21,7 +22,6 @@ type FilterMode = 'all' | 'show' | 'hide'; export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilterProps) { const { regularTasks } = useTasksContext(); const [isOpen, setIsOpen] = useState(false); - const dropdownRef = useRef(null); // Vérifier quelles sources ont des tâches const sources = useMemo((): SourceOption[] => { @@ -44,17 +44,6 @@ export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilte ].filter(source => source.hasTasks); }, [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 if (sources.length === 0) { @@ -85,6 +74,15 @@ export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilte }; // 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 activeFilters = sources.filter(source => { const mode = getSourceMode(source.id); @@ -102,105 +100,80 @@ export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilte } }; - const getMainButtonStyle = () => { - const activeFilters = sources.filter(source => { - 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 ( -
- {/* Bouton principal */} - - - {/* Dropdown */} - {isOpen && ( -
-
- {sources.map((source) => { - const currentMode = getSourceMode(source.id); - - return ( -
-
- {source.icon} - {source.label} -
- -
- {[ - { mode: 'all' as FilterMode, label: 'Afficher tout', icon: '👁️' }, - { mode: 'show' as FilterMode, label: 'Seulement cette source', icon: '✅' }, - { mode: 'hide' as FilterMode, label: 'Masquer cette source', icon: '🚫' } - ].map(({ mode, label, icon }) => ( - - ))} -
-
- ); - })} + const dropdownContent = ( +
+ {sources.map((source) => { + const currentMode = getSourceMode(source.id); + + return ( +
+
+ {source.icon} + {source.label} +
- {/* Option pour réinitialiser tous les filtres */} -
- +
+ {[ + { mode: 'all' as FilterMode, label: 'Afficher tout', icon: '👁️' }, + { mode: 'show' as FilterMode, label: 'Seulement cette source', icon: '✅' }, + { mode: 'hide' as FilterMode, label: 'Masquer cette source', icon: '🚫' } + ].map(({ mode, label, icon }) => ( + + ))}
-
- )} + ); + })} + + {/* Option pour réinitialiser tous les filtres */} +
+ +
); + + return ( + + ); } diff --git a/src/components/ui-showcase/UIShowcaseClient.tsx b/src/components/ui-showcase/UIShowcaseClient.tsx index f70c0a2..3788ea0 100644 --- a/src/components/ui-showcase/UIShowcaseClient.tsx +++ b/src/components/ui-showcase/UIShowcaseClient.tsx @@ -9,7 +9,8 @@ import { FormsSection, NavigationSection, FeedbackSection, - DataDisplaySection + DataDisplaySection, + DropdownsSection } from './sections'; export function UIShowcaseClient() { @@ -33,6 +34,7 @@ export function UIShowcaseClient() { + diff --git a/src/components/ui-showcase/sections/DropdownsSection.tsx b/src/components/ui-showcase/sections/DropdownsSection.tsx new file mode 100644 index 0000000..124f951 --- /dev/null +++ b/src/components/ui-showcase/sections/DropdownsSection.tsx @@ -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(''); + + return ( +
+ + {/* Dropdown avec différents placements */} +
+

Placements

+
+ {PLACEMENTS.map(placement => ( + +
+ {placement} +
+ {SAMPLE_OPTIONS.slice(0, 2).map(option => ( + + ))} +
+ } + placement={placement} + /> + ))} +
+
+ + {/* Dropdown avec différents variants */} +
+

Variants

+
+ {VARIANTS.map(variant => ( + +
+ {variant} +
+ {SAMPLE_OPTIONS.slice(0, 2).map(option => ( + + ))} +
+ } + placement="bottom-start" + /> + ))} +
+
+ + {/* Dropdown avec contenu complexe */} +
+

Contenu Complexe

+
+ +
+

Paramètres

+

Configurez vos préférences

+
+ +
+
+ Notifications + +
+ +
+ Mode sombre + +
+ +
+ Langue + +
+
+ +
+ + +
+
+ } + placement="bottom-start" + /> +
+ + + {/* Dropdown avec désactivation */} +
+

États

+
+ + {SAMPLE_OPTIONS.map(option => ( + + ))} +
+ } + placement="bottom-start" + /> + + + {SAMPLE_OPTIONS.map(option => ( + + ))} +
+ } + placement="bottom-start" + /> + + + + + ); +} diff --git a/src/components/ui-showcase/sections/index.ts b/src/components/ui-showcase/sections/index.ts index 9422526..aa59a31 100644 --- a/src/components/ui-showcase/sections/index.ts +++ b/src/components/ui-showcase/sections/index.ts @@ -5,3 +5,4 @@ export { FormsSection } from './FormsSection'; export { NavigationSection } from './NavigationSection'; export { FeedbackSection } from './FeedbackSection'; export { DataDisplaySection } from './DataDisplaySection'; +export { DropdownsSection } from './DropdownsSection'; diff --git a/src/components/ui/Dropdown.tsx b/src/components/ui/Dropdown.tsx new file mode 100644 index 0000000..7c60bc0 --- /dev/null +++ b/src/components/ui/Dropdown.tsx @@ -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(null); + const triggerRef = useRef(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 = ( + + ); + + // Contenu du dropdown + const dropdownContent = open && mounted && positionCalculated && ( +
+
+ {content} +
+
+ ); + + return ( + <> + {triggerElement} + {mounted && createPortal(dropdownContent, document.body)} + + ); +} diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index f53b8a9..40d7a35 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -3,6 +3,7 @@ export { Button } from './Button'; export { Badge } from './Badge'; export { Alert, AlertTitle, AlertDescription } from './Alert'; export { Input } from './Input'; +export { Card, CardHeader, CardTitle, CardContent, CardFooter } from './Card'; export { StyledCard } from './StyledCard'; // Composants Dashboard @@ -43,7 +44,8 @@ export { SkeletonCard, SkeletonGrid } from './SkeletonCard'; export { MetricsGrid } from './MetricsGrid'; // 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 { Modal } from './Modal'; export { ConfirmModal } from './ConfirmModal';