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:
@@ -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<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é
|
||||
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 */}
|
||||
<div className="relative" ref={sortDropdownRef}>
|
||||
<Button
|
||||
ref={sortButtonRef}
|
||||
variant="ghost"
|
||||
onClick={handleSortToggle}
|
||||
className="flex items-center gap-2"
|
||||
<Dropdown
|
||||
open={isSortOpen}
|
||||
onOpenChange={setIsSortOpen}
|
||||
trigger="☰ Tris"
|
||||
content={
|
||||
<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
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
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>
|
||||
|
||||
<span className="text-base">{option.icon}</span>
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
placement="bottom-start"
|
||||
className="w-80"
|
||||
/>
|
||||
|
||||
</ControlSection>
|
||||
|
||||
@@ -283,42 +253,6 @@ export function KanbanFilters({ filters, onFiltersChange, hiddenStatuses: propsH
|
||||
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<HTMLDivElement>(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,43 +100,8 @@ 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 (
|
||||
<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">
|
||||
const dropdownContent = (
|
||||
<div className="space-y-3">
|
||||
{sources.map((source) => {
|
||||
const currentMode = getSourceMode(source.id);
|
||||
|
||||
@@ -179,7 +142,9 @@ export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilte
|
||||
|
||||
{/* Option pour réinitialiser tous les filtres */}
|
||||
<div className="border-t border-[var(--border)] pt-2 mt-2">
|
||||
<button
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const updates: Partial<KanbanFilters> = {
|
||||
showJiraOnly: false,
|
||||
@@ -188,19 +153,27 @@ export function SourceQuickFilter({ filters, onFiltersChange }: SourceQuickFilte
|
||||
hideTfsTasks: false
|
||||
};
|
||||
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"
|
||||
>
|
||||
<span>🔄</span>
|
||||
<span className="flex-1">Réinitialiser tout</span>
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
trigger={`🔌 ${getMainButtonText()}`}
|
||||
variant={getMainButtonVariant()}
|
||||
content={dropdownContent}
|
||||
placement="bottom-start"
|
||||
className="min-w-[240px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import {
|
||||
FormsSection,
|
||||
NavigationSection,
|
||||
FeedbackSection,
|
||||
DataDisplaySection
|
||||
DataDisplaySection,
|
||||
DropdownsSection
|
||||
} from './sections';
|
||||
|
||||
export function UIShowcaseClient() {
|
||||
@@ -33,6 +34,7 @@ export function UIShowcaseClient() {
|
||||
<ButtonsSection />
|
||||
<BadgesSection />
|
||||
<CardsSection />
|
||||
<DropdownsSection />
|
||||
<FormsSection />
|
||||
<NavigationSection />
|
||||
<FeedbackSection />
|
||||
|
||||
257
src/components/ui-showcase/sections/DropdownsSection.tsx
Normal file
257
src/components/ui-showcase/sections/DropdownsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export { FormsSection } from './FormsSection';
|
||||
export { NavigationSection } from './NavigationSection';
|
||||
export { FeedbackSection } from './FeedbackSection';
|
||||
export { DataDisplaySection } from './DataDisplaySection';
|
||||
export { DropdownsSection } from './DropdownsSection';
|
||||
|
||||
295
src/components/ui/Dropdown.tsx
Normal file
295
src/components/ui/Dropdown.tsx
Normal 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)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user