feat: Enhance TagInput component with option handling and improved tag addition logic

This commit is contained in:
web 2025-09-03 07:57:48 -07:00
parent e63f823b0b
commit b6e778d482

View File

@ -2,7 +2,7 @@ import { Badge } from '@workspace/ui/components/badge';
import { Input } from '@workspace/ui/components/input'; import { Input } from '@workspace/ui/components/input';
import { cn } from '@workspace/ui/lib/utils'; import { cn } from '@workspace/ui/lib/utils';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import React, { useEffect, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
interface TagInputProps { interface TagInputProps {
value?: string[]; value?: string[];
@ -10,6 +10,7 @@ interface TagInputProps {
placeholder?: string; placeholder?: string;
separator?: string; separator?: string;
className?: string; className?: string;
options?: string[];
} }
export function TagInput({ export function TagInput({
@ -18,9 +19,24 @@ export function TagInput({
placeholder, placeholder,
separator = ',', separator = ',',
className, className,
options = [
'test',
'example',
'demo',
'sample',
'react',
'javascript',
'typescript',
'nodejs',
'vue',
'angular',
'svelte',
],
}: TagInputProps) { }: TagInputProps) {
const [inputValue, setInputValue] = useState(''); const [inputValue, setInputValue] = useState('');
const [tags, setTags] = useState<string[]>(value); const [tags, setTags] = useState<string[]>(value);
const [open, setOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
setTags(value.map((tag) => tag.trim()).filter((tag) => tag)); setTags(value.map((tag) => tag.trim()).filter((tag) => tag));
@ -30,18 +46,36 @@ export function TagInput({
return input.replace(//g, ','); return input.replace(//g, ',');
} }
function addTag() { function addTag(tagValue?: string) {
const normalizedInput = normalizeInput(inputValue); let tagsToAdd: string[] = [];
const newTags = normalizedInput let shouldKeepOpen = false;
.split(separator)
.map((tag) => tag.trim())
.filter((tag) => tag && !tags.includes(tag));
if (newTags.length > 0) { if (tagValue) {
const updatedTags = [...tags, ...newTags]; 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); updateTags(updatedTags);
} }
setInputValue(''); setInputValue('');
if (shouldKeepOpen && options.length > 0) {
setTimeout(() => {
setOpen(true);
}, 10);
} else {
setOpen(false);
}
} }
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) { function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
@ -51,9 +85,26 @@ export function TagInput({
} else if (event.key === 'Backspace' && inputValue === '') { } else if (event.key === 'Backspace' && inputValue === '') {
event.preventDefault(); event.preventDefault();
handleRemoveTag(tags.length - 1); 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) { function handleRemoveTag(index: number) {
const newTags = tags.filter((_, i) => i !== index); const newTags = tags.filter((_, i) => i !== index);
updateTags(newTags); updateTags(newTags);
@ -64,31 +115,71 @@ export function TagInput({
onChange?.(newTags); onChange?.(newTags);
} }
const availableOptions = options
.filter((option) => !tags.includes(option))
.filter(
(option) =>
inputValue.trim() === '' || option.toLowerCase().includes(inputValue.toLowerCase()),
);
return ( return (
<div <div className={cn('relative', className)}>
className={cn( <div
'border-input focus-within:ring-primary flex min-h-9 w-full flex-wrap items-center gap-2 rounded-md border bg-transparent p-2 shadow-sm transition-colors focus-within:ring-1', className={cn(
className, 'border-input focus-within:ring-primary flex min-h-9 w-full cursor-text flex-wrap items-center gap-2 rounded-md border bg-transparent p-2 shadow-sm transition-colors focus-within:ring-0',
)} )}
> onClick={() => inputRef.current?.focus()}
{tags.map((tag, index) => ( >
<Badge {tags.map((tag, index) => (
key={tag} <Badge
variant='outline' key={tag}
className='border-primary bg-primary/10 flex items-center gap-1 px-1' variant='outline'
> className='border-primary bg-primary/10 flex items-center gap-1 px-1'
{tag} onClick={(e) => e.stopPropagation()}
<X className='size-4 cursor-pointer' onClick={() => handleRemoveTag(index)} /> >
</Badge> {tag}
))} <X
<Input className='hover:text-destructive size-4 cursor-pointer rounded-sm'
className='h-full min-w-48 flex-1 border-none bg-transparent p-0 shadow-none !ring-0' onClick={(e) => {
value={inputValue} e.stopPropagation();
onChange={(e) => setInputValue(e.target.value)} handleRemoveTag(index);
onKeyDown={handleKeyDown} }}
onBlur={addTag} />
placeholder={placeholder} </Badge>
/> ))}
<div className='flex min-w-0 flex-1 items-center gap-2'>
<Input
ref={inputRef}
className='h-full min-w-0 flex-1 border-none bg-transparent p-0 shadow-none !ring-0'
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={handleInputFocus}
onBlur={handleInputBlur}
placeholder={placeholder}
/>
{open && availableOptions.length > 0 && (
<div className='bg-popover text-popover-foreground absolute left-0 top-full z-50 max-h-60 w-full overflow-auto rounded-md border shadow-md'>
{availableOptions.map((option) => (
<div
key={option}
className='hover:bg-accent hover:text-accent-foreground relative flex cursor-pointer select-none items-center px-2 py-1.5 text-sm'
onMouseDown={(e) => {
e.preventDefault();
addTag(option);
setTimeout(() => {
inputRef.current?.focus();
}, 10);
}}
>
{option}
</div>
))}
</div>
)}
</div>
</div>
</div> </div>
); );
} }