Files
towercontrol/components/ui/TagInput.tsx
Julien Froidefond 523a7f00cc fix: improve QuickAddTask and TagInput components
- Added `min-w-0` class to the title input in `QuickAddTask` for better layout handling.
- Updated priority select to use `flex-shrink-0` and `w-10` for consistent sizing and added title tooltip for better UX.
- Enhanced `TagInput` to support `compactSuggestions` prop, adjusting suggestion display based on available space.
- Modified suggestion container to conditionally apply grid classes based on `compactSuggestions`, improving responsiveness.
2025-09-18 15:44:06 +02:00

200 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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;
compactSuggestions?: boolean; // Pour adapter selon l'espace
}
export function TagInput({
tags,
onChange,
placeholder = "Ajouter des tags...",
maxTags = 10,
className = "",
compactSuggestions = false
}: 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-[9999] max-h-64 overflow-y-auto"
>
{loading ? (
<div className="p-3 text-center text-[var(--muted-foreground)] text-sm">
Recherche...
</div>
) : (
<div className={`gap-2 p-3 ${compactSuggestions ? 'grid grid-cols-1' : 'grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'}`}>
{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>
);
}