maj url photo + page 404 + gestion programmation d'article
This commit is contained in:
172
components/TagInput.tsx
Normal file
172
components/TagInput.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, KeyboardEvent, useEffect, useRef } from 'react';
|
||||
import { X, Hash, Search, TrendingUp } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
value: string[];
|
||||
onChange: (value: string[]) => void;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
suggestions?: string[];
|
||||
}
|
||||
|
||||
export default function TagInput({ value, onChange, label, placeholder, suggestions = [] }: Props) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
const [selectedIndex, setSelectedIndex] = useState(-1);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (inputValue.trim()) {
|
||||
const filtered = suggestions
|
||||
.filter(tag =>
|
||||
tag.toLowerCase().includes(inputValue.toLowerCase()) &&
|
||||
!value.includes(tag.toLowerCase())
|
||||
)
|
||||
.slice(0, 10);
|
||||
setFilteredSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
} else if (showSuggestions && suggestions.length > 0) {
|
||||
const filtered = suggestions
|
||||
.filter(tag => !value.includes(tag.toLowerCase()))
|
||||
.slice(0, 8);
|
||||
setFilteredSuggestions(filtered);
|
||||
setShowSuggestions(filtered.length > 0);
|
||||
} else {
|
||||
setFilteredSuggestions([]);
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
setSelectedIndex(-1);
|
||||
}, [inputValue, suggestions, value, showSuggestions]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setShowSuggestions(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const addTag = (tagToAdd?: string) => {
|
||||
const tag = (tagToAdd || inputValue).trim().toLowerCase();
|
||||
if (tag && !value.includes(tag)) {
|
||||
onChange([...value, tag]);
|
||||
}
|
||||
setInputValue('');
|
||||
if (tagToAdd) setShowSuggestions(true);
|
||||
};
|
||||
|
||||
const removeTag = (tagToRemove: string) => {
|
||||
onChange(value.filter(tag => tag !== tagToRemove));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (selectedIndex >= 0 && filteredSuggestions[selectedIndex]) {
|
||||
addTag(filteredSuggestions[selectedIndex]);
|
||||
} else {
|
||||
addTag();
|
||||
}
|
||||
} else if (e.key === ',' || e.key === ';') {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!showSuggestions && suggestions.length > 0) {
|
||||
setShowSuggestions(true);
|
||||
} else {
|
||||
setSelectedIndex(prev => (prev < filteredSuggestions.length - 1 ? prev + 1 : prev));
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setSelectedIndex(prev => (prev > -1 ? prev - 1 : -1));
|
||||
} else if (e.key === 'Escape') {
|
||||
setShowSuggestions(false);
|
||||
} else if (e.key === 'Backspace' && !inputValue && value.length > 0) {
|
||||
removeTag(value[value.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2 relative" ref={containerRef}>
|
||||
{label && <label className="text-sm font-medium text-gray-700">{label}</label>}
|
||||
|
||||
<div
|
||||
className="min-h-[48px] p-2 bg-white border border-gray-300 rounded-lg flex flex-wrap gap-2 items-center focus-within:border-brand-500 focus-within:ring-1 focus-within:ring-brand-500 transition-all cursor-text shadow-sm"
|
||||
onClick={() => {
|
||||
const input = containerRef.current?.querySelector('input');
|
||||
input?.focus();
|
||||
}}
|
||||
>
|
||||
{value.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="flex items-center gap-1 bg-brand-50 text-brand-700 border border-brand-100 px-2.5 py-1 rounded-md text-sm font-medium animate-in zoom-in-95 duration-200"
|
||||
>
|
||||
<Hash className="w-3 h-3 opacity-50" />
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
removeTag(tag);
|
||||
}}
|
||||
className="hover:text-red-500 transition-colors ml-1"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setShowSuggestions(true)}
|
||||
placeholder={value.length === 0 ? (placeholder || "Ajouter des tags...") : ""}
|
||||
className="flex-1 bg-transparent border-none outline-none text-gray-900 text-sm min-w-[120px] p-1 placeholder:text-gray-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showSuggestions && filteredSuggestions.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-xl overflow-hidden animate-in fade-in slide-in-from-top-1 duration-200">
|
||||
<div className="p-2 border-b border-gray-100 bg-gray-50 flex items-center justify-between">
|
||||
<p className="text-[10px] font-bold text-gray-500 uppercase tracking-widest flex items-center gap-1.5">
|
||||
{inputValue ? <Search className="w-3 h-3" /> : <TrendingUp className="w-3 h-3" />}
|
||||
{inputValue ? 'Suggestions' : 'Tags populaires'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filteredSuggestions.map((suggestion, index) => (
|
||||
<button
|
||||
key={suggestion}
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
addTag(suggestion);
|
||||
}}
|
||||
onMouseEnter={() => setSelectedIndex(index)}
|
||||
className={`w-full text-left px-4 py-2.5 text-sm flex items-center justify-between transition-colors ${
|
||||
index === selectedIndex ? 'bg-brand-600 text-white' : 'text-gray-700 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
<Hash className={`w-3.5 h-3.5 ${index === selectedIndex ? 'text-white/50' : 'text-gray-400'}`} />
|
||||
{suggestion}
|
||||
</span>
|
||||
{index === selectedIndex && (
|
||||
<span className="text-[10px] bg-white/20 px-1.5 py-0.5 rounded font-bold uppercase">Entrée</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user