- Added theme context and provider for light/dark mode support. - Integrated theme toggle button in the Header component. - Updated UI components to utilize CSS variables for consistent theming. - Enhanced Kanban components and forms with new theme styles for better visual coherence. - Adjusted global styles to define color variables for both themes, improving maintainability.
198 lines
6.7 KiB
TypeScript
198 lines
6.7 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useRef, useEffect } from 'react';
|
||
import { Tag } from '@/lib/types';
|
||
import { useTagsAutocomplete } from '@/hooks/useTags';
|
||
import { Badge } from './Badge';
|
||
|
||
interface TagInputProps {
|
||
tags: string[];
|
||
onChange: (tags: string[]) => void;
|
||
placeholder?: string;
|
||
maxTags?: number;
|
||
className?: string;
|
||
}
|
||
|
||
export function TagInput({
|
||
tags,
|
||
onChange,
|
||
placeholder = "Ajouter des tags...",
|
||
maxTags = 10,
|
||
className = ""
|
||
}: TagInputProps) {
|
||
const [inputValue, setInputValue] = useState('');
|
||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||
const inputRef = useRef<HTMLInputElement>(null);
|
||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||
|
||
const { suggestions, loading, searchTags, clearSuggestions, loadPopularTags } = useTagsAutocomplete();
|
||
|
||
// Rechercher des suggestions quand l'input change
|
||
useEffect(() => {
|
||
if (inputValue.trim()) {
|
||
searchTags(inputValue);
|
||
setShowSuggestions(true);
|
||
setSelectedIndex(-1);
|
||
} else {
|
||
clearSuggestions();
|
||
setShowSuggestions(false);
|
||
}
|
||
}, [inputValue, searchTags, clearSuggestions]);
|
||
|
||
const addTag = (tagName: string) => {
|
||
const trimmedTag = tagName.trim();
|
||
if (trimmedTag && !tags.includes(trimmedTag) && tags.length < maxTags) {
|
||
onChange([...tags, trimmedTag]);
|
||
}
|
||
setInputValue('');
|
||
setShowSuggestions(false);
|
||
setSelectedIndex(-1);
|
||
};
|
||
|
||
const removeTag = (tagToRemove: string) => {
|
||
onChange(tags.filter(tag => tag !== tagToRemove));
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === 'Enter') {
|
||
e.preventDefault();
|
||
if (selectedIndex >= 0 && suggestions[selectedIndex]) {
|
||
addTag(suggestions[selectedIndex].name);
|
||
} else if (inputValue.trim()) {
|
||
addTag(inputValue);
|
||
}
|
||
} else if (e.key === 'Escape') {
|
||
setShowSuggestions(false);
|
||
setSelectedIndex(-1);
|
||
} else if (e.key === 'ArrowDown') {
|
||
e.preventDefault();
|
||
setSelectedIndex(prev =>
|
||
prev < suggestions.length - 1 ? prev + 1 : prev
|
||
);
|
||
} else if (e.key === 'ArrowUp') {
|
||
e.preventDefault();
|
||
setSelectedIndex(prev => prev > 0 ? prev - 1 : -1);
|
||
} else if (e.key === 'Backspace' && !inputValue && tags.length > 0) {
|
||
// Supprimer le dernier tag si l'input est vide
|
||
removeTag(tags[tags.length - 1]);
|
||
}
|
||
};
|
||
|
||
const handleSuggestionClick = (tag: Tag) => {
|
||
addTag(tag.name);
|
||
};
|
||
|
||
const handleBlur = (e: React.FocusEvent) => {
|
||
// Délai pour permettre le clic sur une suggestion
|
||
setTimeout(() => {
|
||
if (!suggestionsRef.current?.contains(e.relatedTarget as Node)) {
|
||
setShowSuggestions(false);
|
||
setSelectedIndex(-1);
|
||
}
|
||
}, 150);
|
||
};
|
||
|
||
const handleFocus = () => {
|
||
if (inputValue.trim()) {
|
||
// Si il y a du texte, afficher les suggestions existantes
|
||
setShowSuggestions(true);
|
||
} else {
|
||
// Si l'input est vide, charger les tags populaires
|
||
loadPopularTags(20);
|
||
setShowSuggestions(true);
|
||
}
|
||
setSelectedIndex(-1);
|
||
};
|
||
|
||
return (
|
||
<div className={`relative ${className}`}>
|
||
{/* Container des tags et input */}
|
||
<div className="min-h-[42px] p-2 border border-[var(--border)] rounded-lg bg-[var(--input)] focus-within:border-[var(--primary)] focus-within:ring-1 focus-within:ring-[var(--primary)]/20 transition-colors">
|
||
<div className="flex flex-wrap gap-1 items-center">
|
||
{/* Tags existants */}
|
||
{tags.map((tag, index) => (
|
||
<Badge
|
||
key={index}
|
||
variant="default"
|
||
className="flex items-center gap-1 px-2 py-1 text-xs"
|
||
>
|
||
<span>{tag}</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => removeTag(tag)}
|
||
className="text-[var(--muted-foreground)] hover:text-[var(--foreground)] ml-1"
|
||
aria-label={`Supprimer le tag ${tag}`}
|
||
>
|
||
×
|
||
</button>
|
||
</Badge>
|
||
))}
|
||
|
||
{/* Input pour nouveau tag */}
|
||
{tags.length < maxTags && (
|
||
<input
|
||
ref={inputRef}
|
||
type="text"
|
||
value={inputValue}
|
||
onChange={(e) => setInputValue(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
onBlur={handleBlur}
|
||
onFocus={handleFocus}
|
||
placeholder={tags.length === 0 ? placeholder : ""}
|
||
className="flex-1 min-w-[120px] bg-transparent border-none outline-none text-[var(--foreground)] placeholder-[var(--muted-foreground)] text-sm"
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Suggestions dropdown */}
|
||
{showSuggestions && (suggestions.length > 0 || loading) && (
|
||
<div
|
||
ref={suggestionsRef}
|
||
className="absolute top-full left-0 right-0 mt-1 bg-[var(--card)] border border-[var(--border)] rounded-lg shadow-lg z-50 max-h-64 overflow-y-auto"
|
||
>
|
||
{loading ? (
|
||
<div className="p-3 text-center text-[var(--muted-foreground)] text-sm">
|
||
Recherche...
|
||
</div>
|
||
) : (
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2 p-3">
|
||
{suggestions.map((tag, index) => (
|
||
<button
|
||
key={tag.id}
|
||
type="button"
|
||
onClick={() => handleSuggestionClick(tag)}
|
||
className={`flex items-center gap-2 px-2 py-1.5 text-xs rounded-md transition-colors ${
|
||
index === selectedIndex
|
||
? 'bg-[var(--card-hover)] text-[var(--primary)] ring-1 ring-[var(--primary)]'
|
||
: 'text-[var(--foreground)] hover:bg-[var(--card-hover)]'
|
||
} ${tags.includes(tag.name) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||
disabled={tags.includes(tag.name)}
|
||
title={tags.includes(tag.name) ? 'Déjà ajouté' : `Ajouter ${tag.name}`}
|
||
>
|
||
<div
|
||
className="w-2 h-2 rounded-full flex-shrink-0"
|
||
style={{ backgroundColor: tag.color }}
|
||
/>
|
||
<span className="truncate">{tag.name}</span>
|
||
{tags.includes(tag.name) && (
|
||
<span className="text-[var(--muted-foreground)] ml-auto">✓</span>
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* Indicateur de limite */}
|
||
{tags.length >= maxTags && (
|
||
<div className="text-xs text-[var(--muted-foreground)] mt-1">
|
||
Limite de {maxTags} tags atteinte
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|