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:
@@ -31,11 +31,9 @@ export function TagInput({
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: 0,
|
||||
});
|
||||
const [hasLoadedPopularTags, setHasLoadedPopularTags] = useState(false);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||
const [positionCalculated, setPositionCalculated] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const suggestionsRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -49,6 +47,20 @@ export function TagInput({
|
||||
} = useTagsAutocomplete();
|
||||
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
|
||||
useEffect(() => {
|
||||
const loadTags = async () => {
|
||||
@@ -63,17 +75,15 @@ export function TagInput({
|
||||
loadTags();
|
||||
}, []);
|
||||
|
||||
// Calculer la position du dropdown
|
||||
const updateDropdownPosition = () => {
|
||||
if (containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
setDropdownPosition({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX,
|
||||
width: rect.width,
|
||||
});
|
||||
// Mettre à jour la position quand le dropdown s'ouvre (comme dans Dropdown)
|
||||
useEffect(() => {
|
||||
if (showSuggestions) {
|
||||
setPositionCalculated(false);
|
||||
calculatePosition();
|
||||
} else {
|
||||
setPositionCalculated(false);
|
||||
}
|
||||
};
|
||||
}, [showSuggestions]);
|
||||
|
||||
// Rechercher des suggestions quand l'input change
|
||||
useEffect(() => {
|
||||
@@ -81,29 +91,15 @@ export function TagInput({
|
||||
searchTags(inputValue);
|
||||
setShowSuggestions(true);
|
||||
setSelectedIndex(-1);
|
||||
setHasLoadedPopularTags(false); // Reset flag when typing
|
||||
} else {
|
||||
clearSuggestions();
|
||||
setShowSuggestions(false);
|
||||
// Only hide suggestions if we haven't just loaded popular tags
|
||||
if (!hasLoadedPopularTags) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
}
|
||||
}, [inputValue, searchTags, clearSuggestions]);
|
||||
|
||||
// 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]);
|
||||
}, [inputValue, searchTags, clearSuggestions, hasLoadedPopularTags]);
|
||||
|
||||
const addTag = (tagName: string) => {
|
||||
const trimmedTag = tagName.trim();
|
||||
@@ -170,23 +166,13 @@ export function TagInput({
|
||||
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 = () => {
|
||||
updateDropdownPosition();
|
||||
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
|
||||
setHasLoadedPopularTags(true);
|
||||
loadPopularTags(20);
|
||||
setShowSuggestions(true);
|
||||
}
|
||||
@@ -252,7 +238,6 @@ export function TagInput({
|
||||
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"
|
||||
@@ -261,64 +246,61 @@ export function TagInput({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions dropdown rendu via portail pour éviter les problèmes de z-index */}
|
||||
{/* Suggestions dropdown */}
|
||||
{showSuggestions &&
|
||||
(suggestions.length > 0 || loading) &&
|
||||
positionCalculated &&
|
||||
typeof window !== 'undefined' &&
|
||||
createPortal(
|
||||
<div
|
||||
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={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
width: dropdownPosition.width,
|
||||
top: `${dropdownPosition.top}px`,
|
||||
left: `${dropdownPosition.left}px`,
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
<div className="relative z-10">
|
||||
{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 border ${
|
||||
index === selectedIndex
|
||||
? 'bg-[var(--primary)]/15 text-[var(--primary)] ring-1 ring-[var(--primary)]/40 border-[var(--primary)]/40 backdrop-blur-sm'
|
||||
: 'text-[var(--foreground)] border-transparent hover:bg-[var(--card)]/40 hover:border-[var(--border)]/40 backdrop-blur-sm'
|
||||
} ${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 text-[var(--foreground)]">
|
||||
{tag.name}
|
||||
{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 border ${
|
||||
index === selectedIndex
|
||||
? 'bg-[var(--primary)]/15 text-[var(--primary)] ring-1 ring-[var(--primary)]/40 border-[var(--primary)]/40 backdrop-blur-sm'
|
||||
: 'text-[var(--foreground)] border-transparent hover:bg-[var(--card)]/40 hover:border-[var(--border)]/40 backdrop-blur-sm'
|
||||
} ${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 text-[var(--foreground)]">
|
||||
{tag.name}
|
||||
</span>
|
||||
{tags.includes(tag.name) && (
|
||||
<span className="text-[var(--muted-foreground)] ml-auto">
|
||||
✓
|
||||
</span>
|
||||
{tags.includes(tag.name) && (
|
||||
<span className="text-[var(--muted-foreground)] ml-auto">
|
||||
✓
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user