✨ feat: Enhance TagInput component with option handling and improved tag addition logic
This commit is contained in:
parent
e63f823b0b
commit
b6e778d482
@ -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<string[]>(value);
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) {
|
||||
@ -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 (
|
||||
<div
|
||||
className={cn(
|
||||
'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,
|
||||
)}
|
||||
>
|
||||
{tags.map((tag, index) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant='outline'
|
||||
className='border-primary bg-primary/10 flex items-center gap-1 px-1'
|
||||
>
|
||||
{tag}
|
||||
<X className='size-4 cursor-pointer' onClick={() => handleRemoveTag(index)} />
|
||||
</Badge>
|
||||
))}
|
||||
<Input
|
||||
className='h-full min-w-48 flex-1 border-none bg-transparent p-0 shadow-none !ring-0'
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={addTag}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
<div className={cn('relative', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'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
|
||||
key={tag}
|
||||
variant='outline'
|
||||
className='border-primary bg-primary/10 flex items-center gap-1 px-1'
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{tag}
|
||||
<X
|
||||
className='hover:text-destructive size-4 cursor-pointer rounded-sm'
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleRemoveTag(index);
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user