Created
August 28, 2025 10:17
-
-
Save guillaume-rygn/fafd8881ec404d197f28cd7be720cf5f to your computer and use it in GitHub Desktop.
tag input
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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