Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save guillaume-rygn/fafd8881ec404d197f28cd7be720cf5f to your computer and use it in GitHub Desktop.

Select an option

Save guillaume-rygn/fafd8881ec404d197f28cd7be720cf5f to your computer and use it in GitHub Desktop.
tag input
import React, { useState, useRef, forwardRef, useImperativeHandle } from 'react';
import { AlertTriangle, X } from 'lucide-react';
type TagInputProps = {
placeholder?: string;
initialTags?: string[];
required?: boolean;
disabled?: boolean;
className?: string;
id?: string;
name?: string;
'aria-label'?: string;
'aria-describedby'?: string;
hasError?: boolean;
onChange?: (tags: string[], isValid: boolean) => void;
onBlur?: () => void;
onFocus?: () => void;
};
export type TagInputRef = {
getTags: () => string[];
getValidTags: () => string[];
getInvalidTags: () => string[];
isValid: () => boolean;
focus: () => void;
hasInvalidTags: () => boolean;
};
const TagInput = forwardRef<TagInputRef, TagInputProps>(({
placeholder = "Saisir des emails...",
initialTags = [],
required = false,
disabled = false,
className = '',
id,
name,
'aria-label': ariaLabel,
'aria-describedby': ariaDescribedBy,
hasError = false,
onChange,
onBlur,
onFocus
}, ref) => {
const [tags, setTags] = useState<string[]>(initialTags);
const [inputValue, setInputValue] = useState<string>('');
const [isFocused, setIsFocused] = useState<boolean>(false);
const inputRef = useRef<HTMLInputElement | null>(null);
const isValidEmail = (email: string): boolean => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email.trim());
};
const getValidTags = () => tags.filter(tag => isValidEmail(tag));
const getInvalidTags = () => tags.filter(tag => !isValidEmail(tag));
const hasInvalidTags = () => getInvalidTags().length > 0;
const isFormValid = () => {
const validTags = getValidTags();
const invalidTags = getInvalidTags();
if (required && (tags.length === 0 || validTags.length === 0)) {
return false;
}
return invalidTags.length === 0;
};
useImperativeHandle(ref, () => ({
getTags: () => tags,
getValidTags,
getInvalidTags,
isValid: isFormValid,
focus: () => inputRef.current?.focus(),
hasInvalidTags
}));
const updateTags = (newTags: string[]) => {
setTags(newTags);
if (onChange) {
const isValid = isFormValid();
onChange(newTags, isValid);
}
};
const addTag = (value: string) => {
if (disabled) return;
const trimmedValue = value.trim();
if (trimmedValue && !tags.includes(trimmedValue)) {
const newTags = [...tags, trimmedValue];
updateTags(newTags);
}
};
const removeTag = (indexToRemove: number) => {
if (disabled) return;
const newTags = tags.filter((_, index) => index !== indexToRemove);
updateTags(newTags);
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (e.key === ',' || e.key === 'Enter') {
e.preventDefault();
if (inputValue.trim()) {
addTag(inputValue);
setInputValue('');
}
} else if (e.key === 'Backspace' && inputValue === '' && tags.length > 0) {
removeTag(tags.length - 1);
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (disabled) return;
const value = e.target.value;
if (value.includes(',')) {
const newTag = value.replace(',', '').trim();
if (newTag) {
addTag(newTag);
}
setInputValue('');
} else {
setInputValue(value);
}
};
const handleBlur = () => {
setIsFocused(false);
if (!disabled && inputValue.trim()) {
addTag(inputValue);
setInputValue('');
}
onBlur?.();
};
const handleFocus = () => {
if (!disabled) {
setIsFocused(true);
onFocus?.();
}
};
const handleContainerClick = () => {
if (!disabled) {
inputRef.current?.focus();
}
};
const getBorderColor = () => {
if (disabled) return 'border-gray-200 bg-gray-50';
if (hasError) return 'border-red-200';
if (isFocused) return 'border-blue-500 ring-1 ring-blue-200';
return 'border-gray-200 hover:border-gray-300';
};
return (
<div className={className}>
<div
className={`
min-h-26 p-3 border rounded-lg cursor-text transition-all duration-200
${getBorderColor()}
${disabled ? 'cursor-not-allowed' : 'bg-white'}
`}
onClick={handleContainerClick}
>
<div className="flex flex-wrap gap-2 items-center">
{tags.map((tag, index) => (
<div
key={index}
className={`
flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-md text-sm font-medium
${isValidEmail(tag)
? 'bg-gray-50 border border-gray-200'
: 'bg-gray-50 border border-[#ffdcdb]'
}
${disabled ? 'opacity-50' : ''}
`}
>
{!isValidEmail(tag) && (
<AlertTriangle size={"1em"} className='text-[#ED3B3B]'/>
)}
<span className="max-w-48 truncate">{tag}</span>
{!disabled && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
removeTag(index);
}}
className="ml-1 text-gray-400 rounded-full transition-colors p-0.5 rounded-md hover:bg-gray-100 cursor-pointer"
>
<X size={14} />
</button>
)}
</div>
))}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
onBlur={handleBlur}
placeholder={tags.length === 0 ? placeholder : ''}
disabled={disabled}
className="flex-1 min-w-32 font-medium outline-none bg-transparent text-sm disabled:cursor-not-allowed disabled:text-gray-400"
id={id}
name={name}
aria-label={ariaLabel}
aria-describedby={ariaDescribedBy}
aria-required={required}
aria-invalid={hasError || hasInvalidTags() || (required && tags.length === 0)}
/>
</div>
</div>
</div>
);
});
TagInput.displayName = 'TagInput';
export default TagInput;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment