refactor(TagInput): optimize dropdown position handling and improve tag loading logic

- Replaced the dropdown position update logic with a dedicated calculatePosition function for clarity.
- Introduced a new state to track if popular tags have been loaded, enhancing the suggestion display logic.
- Cleaned up unnecessary event listeners and streamlined the component's focus handling.
This commit is contained in:
Julien Froidefond
2025-10-09 21:47:59 +02:00
parent 7d4ab33fca
commit 0b17934ca1

View File

@@ -31,11 +31,9 @@ export function TagInput({
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false); const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1); const [selectedIndex, setSelectedIndex] = useState(-1);
const [dropdownPosition, setDropdownPosition] = useState({ const [hasLoadedPopularTags, setHasLoadedPopularTags] = useState(false);
top: 0, const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
left: 0, const [positionCalculated, setPositionCalculated] = useState(false);
width: 0,
});
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const suggestionsRef = useRef<HTMLDivElement>(null); const suggestionsRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -49,6 +47,20 @@ export function TagInput({
} = useTagsAutocomplete(); } = useTagsAutocomplete();
const [allTags, setAllTags] = useState<Tag[]>([]); const [allTags, setAllTags] = useState<Tag[]>([]);
// Calculer la position du dropdown (copié du composant Dropdown)
const calculatePosition = () => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
// Placement bottom-start (comme dans Dropdown)
const top = rect.bottom + 4;
const left = rect.left;
setDropdownPosition({ top, left });
setPositionCalculated(true);
};
// Charger tous les tags au début pour pouvoir identifier le tag prioritaire // Charger tous les tags au début pour pouvoir identifier le tag prioritaire
useEffect(() => { useEffect(() => {
const loadTags = async () => { const loadTags = async () => {
@@ -63,17 +75,15 @@ export function TagInput({
loadTags(); loadTags();
}, []); }, []);
// Calculer la position du dropdown // Mettre à jour la position quand le dropdown s'ouvre (comme dans Dropdown)
const updateDropdownPosition = () => { useEffect(() => {
if (containerRef.current) { if (showSuggestions) {
const rect = containerRef.current.getBoundingClientRect(); setPositionCalculated(false);
setDropdownPosition({ calculatePosition();
top: rect.bottom + window.scrollY + 4, } else {
left: rect.left + window.scrollX, setPositionCalculated(false);
width: rect.width,
});
} }
}; }, [showSuggestions]);
// Rechercher des suggestions quand l'input change // Rechercher des suggestions quand l'input change
useEffect(() => { useEffect(() => {
@@ -81,29 +91,15 @@ export function TagInput({
searchTags(inputValue); searchTags(inputValue);
setShowSuggestions(true); setShowSuggestions(true);
setSelectedIndex(-1); setSelectedIndex(-1);
setHasLoadedPopularTags(false); // Reset flag when typing
} else { } else {
clearSuggestions(); clearSuggestions();
setShowSuggestions(false); // Only hide suggestions if we haven't just loaded popular tags
if (!hasLoadedPopularTags) {
setShowSuggestions(false);
}
} }
}, [inputValue, searchTags, clearSuggestions]); }, [inputValue, searchTags, clearSuggestions, hasLoadedPopularTags]);
// Mettre à jour la position du dropdown quand nécessaire
useEffect(() => {
if (showSuggestions) {
updateDropdownPosition();
const handleResize = () => updateDropdownPosition();
const handleScroll = () => updateDropdownPosition();
window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleScroll, true);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleScroll, true);
};
}
}, [showSuggestions]);
const addTag = (tagName: string) => { const addTag = (tagName: string) => {
const trimmedTag = tagName.trim(); const trimmedTag = tagName.trim();
@@ -170,23 +166,13 @@ export function TagInput({
addTag(tag.name); 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 = () => { const handleFocus = () => {
updateDropdownPosition();
if (inputValue.trim()) { if (inputValue.trim()) {
// Si il y a du texte, afficher les suggestions existantes // Si il y a du texte, afficher les suggestions existantes
setShowSuggestions(true); setShowSuggestions(true);
} else { } else {
// Si l'input est vide, charger les tags populaires // Si l'input est vide, charger les tags populaires
setHasLoadedPopularTags(true);
loadPopularTags(20); loadPopularTags(20);
setShowSuggestions(true); setShowSuggestions(true);
} }
@@ -252,7 +238,6 @@ export function TagInput({
value={inputValue} value={inputValue}
onChange={(e) => setInputValue(e.target.value)} onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleBlur}
onFocus={handleFocus} onFocus={handleFocus}
placeholder={tags.length === 0 ? placeholder : ''} 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" className="flex-1 min-w-[120px] bg-transparent border-none outline-none text-[var(--foreground)] placeholder-[var(--muted-foreground)] text-sm"
@@ -261,64 +246,61 @@ export function TagInput({
</div> </div>
</div> </div>
{/* Suggestions dropdown rendu via portail pour éviter les problèmes de z-index */} {/* Suggestions dropdown */}
{showSuggestions && {showSuggestions &&
(suggestions.length > 0 || loading) && (suggestions.length > 0 || loading) &&
positionCalculated &&
typeof window !== 'undefined' && typeof window !== 'undefined' &&
createPortal( createPortal(
<div <div
ref={suggestionsRef} ref={suggestionsRef}
className="fixed z-[9999] max-h-64 overflow-y-auto rounded-xl border border-[var(--border)]/60 bg-[var(--card)]/80 backdrop-blur-xl shadow-2xl shadow-[var(--card-shadow-medium)] relative" className="fixed z-[99999] max-h-64 overflow-y-auto rounded-xl border border-[var(--border)]/60 bg-[var(--card)]/80 backdrop-blur-xl shadow-2xl shadow-[var(--card-shadow-medium)]"
style={{ style={{
top: dropdownPosition.top, top: `${dropdownPosition.top}px`,
left: dropdownPosition.left, left: `${dropdownPosition.left}px`,
width: dropdownPosition.width,
}} }}
> >
<div className="absolute inset-0 rounded-xl bg-gradient-to-br from-[color-mix(in_srgb,var(--primary)_12%,transparent)] via-[color-mix(in_srgb,var(--primary)_6%,transparent)] to-transparent opacity-90 pointer-events-none" /> {loading ? (
<div className="relative z-10"> <div className="p-3 text-center text-[var(--muted-foreground)] text-sm">
{loading ? ( Recherche...
<div className="p-3 text-center text-[var(--muted-foreground)] text-sm"> </div>
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'}`}
<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
{suggestions.map((tag, index) => ( key={tag.id}
<button type="button"
key={tag.id} onClick={() => handleSuggestionClick(tag)}
type="button" className={`flex items-center gap-2 px-2 py-1.5 text-xs rounded-md transition-colors border ${
onClick={() => handleSuggestionClick(tag)} index === selectedIndex
className={`flex items-center gap-2 px-2 py-1.5 text-xs rounded-md transition-colors border ${ ? 'bg-[var(--primary)]/15 text-[var(--primary)] ring-1 ring-[var(--primary)]/40 border-[var(--primary)]/40 backdrop-blur-sm'
index === selectedIndex : 'text-[var(--foreground)] border-transparent hover:bg-[var(--card)]/40 hover:border-[var(--border)]/40 backdrop-blur-sm'
? 'bg-[var(--primary)]/15 text-[var(--primary)] ring-1 ring-[var(--primary)]/40 border-[var(--primary)]/40 backdrop-blur-sm' } ${tags.includes(tag.name) ? 'opacity-50 cursor-not-allowed' : ''}`}
: 'text-[var(--foreground)] border-transparent hover:bg-[var(--card)]/40 hover:border-[var(--border)]/40 backdrop-blur-sm' disabled={tags.includes(tag.name)}
} ${tags.includes(tag.name) ? 'opacity-50 cursor-not-allowed' : ''}`} title={
disabled={tags.includes(tag.name)} tags.includes(tag.name)
title={ ? 'Déjà ajouté'
tags.includes(tag.name) : `Ajouter ${tag.name}`
? 'Déjà ajouté' }
: `Ajouter ${tag.name}` >
} <div
> className="w-2 h-2 rounded-full flex-shrink-0"
<div style={{ backgroundColor: tag.color }}
className="w-2 h-2 rounded-full flex-shrink-0" />
style={{ backgroundColor: tag.color }} <span className="truncate text-[var(--foreground)]">
/> {tag.name}
<span className="truncate text-[var(--foreground)]"> </span>
{tag.name} {tags.includes(tag.name) && (
<span className="text-[var(--muted-foreground)] ml-auto">
</span> </span>
{tags.includes(tag.name) && ( )}
<span className="text-[var(--muted-foreground)] ml-auto"> </button>
))}
</span> </div>
)} )}
</button>
))}
</div>
)}
</div>
</div>, </div>,
document.body document.body
)} )}