diff --git a/packages/ui/src/custom-components/tag-input.tsx b/packages/ui/src/custom-components/tag-input.tsx index be104ab..fac2a35 100644 --- a/packages/ui/src/custom-components/tag-input.tsx +++ b/packages/ui/src/custom-components/tag-input.tsx @@ -2,7 +2,7 @@ import { Badge } from '@workspace/ui/components/badge'; import { Input } from '@workspace/ui/components/input'; import { cn } from '@workspace/ui/lib/utils'; import { X } from 'lucide-react'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; interface TagInputProps { value?: string[]; @@ -10,6 +10,7 @@ interface TagInputProps { placeholder?: string; separator?: string; className?: string; + options?: string[]; } export function TagInput({ @@ -18,9 +19,24 @@ export function TagInput({ placeholder, separator = ',', className, + options = [ + 'test', + 'example', + 'demo', + 'sample', + 'react', + 'javascript', + 'typescript', + 'nodejs', + 'vue', + 'angular', + 'svelte', + ], }: TagInputProps) { const [inputValue, setInputValue] = useState(''); const [tags, setTags] = useState(value); + const [open, setOpen] = useState(false); + const inputRef = useRef(null); useEffect(() => { setTags(value.map((tag) => tag.trim()).filter((tag) => tag)); @@ -30,18 +46,36 @@ export function TagInput({ return input.replace(/,/g, ','); } - function addTag() { - const normalizedInput = normalizeInput(inputValue); - const newTags = normalizedInput - .split(separator) - .map((tag) => tag.trim()) - .filter((tag) => tag && !tags.includes(tag)); + function addTag(tagValue?: string) { + let tagsToAdd: string[] = []; + let shouldKeepOpen = false; - if (newTags.length > 0) { - const updatedTags = [...tags, ...newTags]; + if (tagValue) { + if (!tags.includes(tagValue)) { + tagsToAdd = [tagValue]; + shouldKeepOpen = true; + } + } else if (inputValue.trim()) { + const normalizedInput = normalizeInput(inputValue); + tagsToAdd = normalizedInput + .split(separator) + .map((tag) => tag.trim()) + .filter((tag) => tag && !tags.includes(tag)); + } + + if (tagsToAdd.length > 0) { + const updatedTags = [...tags, ...tagsToAdd]; updateTags(updatedTags); } setInputValue(''); + + if (shouldKeepOpen && options.length > 0) { + setTimeout(() => { + setOpen(true); + }, 10); + } else { + setOpen(false); + } } function handleKeyDown(event: React.KeyboardEvent) { @@ -51,9 +85,26 @@ export function TagInput({ } else if (event.key === 'Backspace' && inputValue === '') { event.preventDefault(); handleRemoveTag(tags.length - 1); + } else if (event.key === 'Escape') { + setOpen(false); } } + function handleInputFocus() { + if (options.length > 0) { + setOpen(true); + } + } + + function handleInputBlur() { + setTimeout(() => { + if (inputValue.trim()) { + addTag(); + } + setOpen(false); + }, 200); + } + function handleRemoveTag(index: number) { const newTags = tags.filter((_, i) => i !== index); updateTags(newTags); @@ -64,31 +115,71 @@ export function TagInput({ onChange?.(newTags); } + const availableOptions = options + .filter((option) => !tags.includes(option)) + .filter( + (option) => + inputValue.trim() === '' || option.toLowerCase().includes(inputValue.toLowerCase()), + ); + return ( -
- {tags.map((tag, index) => ( - - {tag} - handleRemoveTag(index)} /> - - ))} - setInputValue(e.target.value)} - onKeyDown={handleKeyDown} - onBlur={addTag} - placeholder={placeholder} - /> +
+
inputRef.current?.focus()} + > + {tags.map((tag, index) => ( + e.stopPropagation()} + > + {tag} + { + e.stopPropagation(); + handleRemoveTag(index); + }} + /> + + ))} +
+ setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + onFocus={handleInputFocus} + onBlur={handleInputBlur} + placeholder={placeholder} + /> + + {open && availableOptions.length > 0 && ( +
+ {availableOptions.map((option) => ( +
{ + e.preventDefault(); + addTag(option); + setTimeout(() => { + inputRef.current?.focus(); + }, 10); + }} + > + {option} +
+ ))} +
+ )} +
+
); }