feat: complete tag management and UI integration
- Marked multiple tasks as completed in TODO.md related to tag management features. - Replaced manual tag input with `TagInput` component in `CreateTaskForm`, `EditTaskForm`, and `QuickAddTask` for better UX. - Updated `TaskCard` to display tags using `TagDisplay` with color support. - Enhanced `TasksService` to manage task-tag relationships with CRUD operations. - Integrated tag management into the global context for better accessibility across components.
This commit is contained in:
184
components/ui/TagInput.tsx
Normal file
184
components/ui/TagInput.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'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 } = 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);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`relative ${className}`}>
|
||||
{/* Container des tags et input */}
|
||||
<div className="min-h-[42px] p-2 border border-slate-600 rounded-lg bg-slate-800 focus-within:border-cyan-400 focus-within:ring-1 focus-within:ring-cyan-400/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-slate-400 hover:text-slate-200 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={() => inputValue && setShowSuggestions(true)}
|
||||
placeholder={tags.length === 0 ? placeholder : ""}
|
||||
className="flex-1 min-w-[120px] bg-transparent border-none outline-none text-slate-100 placeholder-slate-400 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-slate-800 border border-slate-600 rounded-lg shadow-lg z-50 max-h-48 overflow-y-auto"
|
||||
>
|
||||
{loading ? (
|
||||
<div className="p-3 text-center text-slate-400 text-sm">
|
||||
Recherche...
|
||||
</div>
|
||||
) : (
|
||||
suggestions.map((tag, index) => (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => handleSuggestionClick(tag)}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors ${
|
||||
index === selectedIndex
|
||||
? 'bg-slate-700 text-cyan-300'
|
||||
: 'text-slate-200 hover:bg-slate-700'
|
||||
} ${tags.includes(tag.name) ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||
disabled={tags.includes(tag.name)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: tag.color }}
|
||||
/>
|
||||
<span>{tag.name}</span>
|
||||
{tags.includes(tag.name) && (
|
||||
<span className="text-xs text-slate-400 ml-auto">Déjà ajouté</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Indicateur de limite */}
|
||||
{tags.length >= maxTags && (
|
||||
<div className="text-xs text-slate-400 mt-1">
|
||||
Limite de {maxTags} tags atteinte
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user