feat: add form input components with tests
- Add TagInput component for equipment tags - Add ExerciseList component for workout exercises - Both components include comprehensive test suites - Add data-testid attributes for testability
This commit is contained in:
239
admin-web/components/exercise-list.test.tsx
Normal file
239
admin-web/components/exercise-list.test.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ExerciseList } from './exercise-list'
|
||||
|
||||
describe('ExerciseList', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render with empty list showing add button', () => {
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Add Exercise')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should add new exercise on button click', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[{ name: 'Push-ups', duration: 20 }]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const addButton = screen.getByText('Add Exercise')
|
||||
await user.click(addButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith([
|
||||
{ name: 'Push-ups', duration: 20 },
|
||||
{ name: '', duration: 20 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should remove exercise on delete click', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[
|
||||
{ name: 'Push-ups', duration: 20 },
|
||||
{ name: 'Squats', duration: 20 },
|
||||
]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const deleteButton = screen.getByTestId('delete-exercise-0')
|
||||
await user.click(deleteButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith([
|
||||
{ name: 'Squats', duration: 20 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should update exercise name', () => {
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[{ name: 'Push-ups', duration: 20 }]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
// Get the input and simulate change directly using fireEvent
|
||||
const nameInput = screen.getByTestId('exercise-name-0')
|
||||
fireEvent.change(nameInput, { target: { value: 'Burpees' } })
|
||||
|
||||
// Check that onChange was called with correct value
|
||||
expect(mockOnChange).toHaveBeenCalledWith([{ name: 'Burpees', duration: 20 }])
|
||||
})
|
||||
|
||||
it('should update exercise duration', () => {
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[{ name: 'Push-ups', duration: 20 }]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
// Get the input and simulate change directly using fireEvent
|
||||
const durationInput = screen.getByTestId('exercise-duration-0')
|
||||
fireEvent.change(durationInput, { target: { value: '30' } })
|
||||
|
||||
// Check that onChange was called with correct value
|
||||
expect(mockOnChange).toHaveBeenCalledWith([{ name: 'Push-ups', duration: 30 }])
|
||||
})
|
||||
|
||||
it('should disable delete button when only one exercise', () => {
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[{ name: 'Push-ups', duration: 20 }]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const deleteButton = screen.getByTestId('delete-exercise-0')
|
||||
expect(deleteButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should use workTimeDefault for new exercises', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[]}
|
||||
onChange={mockOnChange}
|
||||
workTimeDefault={45}
|
||||
/>
|
||||
)
|
||||
|
||||
const addButton = screen.getByText('Add Exercise')
|
||||
await user.click(addButton)
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith([
|
||||
{ name: '', duration: 45 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should move exercise up', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[
|
||||
{ name: 'First', duration: 20 },
|
||||
{ name: 'Second', duration: 20 },
|
||||
]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const upButton = screen.getByTestId('move-up-1')
|
||||
expect(upButton).not.toBeDisabled()
|
||||
|
||||
await user.click(upButton)
|
||||
expect(mockOnChange).toHaveBeenCalledWith([
|
||||
{ name: 'Second', duration: 20 },
|
||||
{ name: 'First', duration: 20 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should move exercise down', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[
|
||||
{ name: 'First', duration: 20 },
|
||||
{ name: 'Second', duration: 20 },
|
||||
]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const downButton = screen.getByTestId('move-down-0')
|
||||
expect(downButton).not.toBeDisabled()
|
||||
|
||||
await user.click(downButton)
|
||||
expect(mockOnChange).toHaveBeenCalledWith([
|
||||
{ name: 'Second', duration: 20 },
|
||||
{ name: 'First', duration: 20 },
|
||||
])
|
||||
})
|
||||
|
||||
it('should disable move up on first item', () => {
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[
|
||||
{ name: 'First', duration: 20 },
|
||||
{ name: 'Second', duration: 20 },
|
||||
]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const upButton = screen.getByTestId('move-up-0')
|
||||
expect(upButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should disable move down on last item', () => {
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[
|
||||
{ name: 'First', duration: 20 },
|
||||
{ name: 'Second', duration: 20 },
|
||||
]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const downButton = screen.getByTestId('move-down-1')
|
||||
expect(downButton).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should respect disabled prop', () => {
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[{ name: 'Push-ups', duration: 20 }]}
|
||||
onChange={mockOnChange}
|
||||
disabled
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('exercise-name-0')).toBeDisabled()
|
||||
expect(screen.getByTestId('exercise-duration-0')).toBeDisabled()
|
||||
expect(screen.getByText('Add Exercise')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should render correct number of exercises', () => {
|
||||
render(
|
||||
<ExerciseList
|
||||
value={[
|
||||
{ name: 'First', duration: 20 },
|
||||
{ name: 'Second', duration: 20 },
|
||||
{ name: 'Third', duration: 20 },
|
||||
]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
// Check that we have 3 exercise name inputs
|
||||
expect(screen.getByTestId('exercise-name-0')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('exercise-name-1')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('exercise-name-2')).toBeInTheDocument()
|
||||
|
||||
// Verify move buttons have correct aria-labels
|
||||
expect(screen.getByTestId('move-up-0')).toHaveAttribute('aria-label', 'Move exercise 1 up')
|
||||
expect(screen.getByTestId('move-up-1')).toHaveAttribute('aria-label', 'Move exercise 2 up')
|
||||
expect(screen.getByTestId('move-up-2')).toHaveAttribute('aria-label', 'Move exercise 3 up')
|
||||
})
|
||||
})
|
||||
161
admin-web/components/exercise-list.tsx
Normal file
161
admin-web/components/exercise-list.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Plus, Trash2, GripVertical } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface Exercise {
|
||||
name: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
interface ExerciseListProps {
|
||||
value: Exercise[]
|
||||
onChange: (value: Exercise[]) => void
|
||||
workTimeDefault?: number
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
function ExerciseList({
|
||||
value,
|
||||
onChange,
|
||||
workTimeDefault = 20,
|
||||
className,
|
||||
disabled = false,
|
||||
}: ExerciseListProps) {
|
||||
const handleAdd = () => {
|
||||
onChange([...value, { name: "", duration: workTimeDefault }])
|
||||
}
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
onChange(value.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleUpdate = (
|
||||
index: number,
|
||||
field: keyof Exercise,
|
||||
newValue: string | number
|
||||
) => {
|
||||
const updated = [...value]
|
||||
updated[index] = { ...updated[index], [field]: newValue }
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
const handleMove = (index: number, direction: "up" | "down") => {
|
||||
if (
|
||||
(direction === "up" && index === 0) ||
|
||||
(direction === "down" && index === value.length - 1)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const newIndex = direction === "up" ? index - 1 : index + 1
|
||||
const updated = [...value]
|
||||
const [moved] = updated.splice(index, 1)
|
||||
updated.splice(newIndex, 0, moved)
|
||||
onChange(updated)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-3", className)}>
|
||||
<div className="space-y-2">
|
||||
{value.map((exercise, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 rounded-md border border-neutral-800 bg-neutral-900/50 p-3"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMove(index, "up")}
|
||||
disabled={disabled || index === 0}
|
||||
className="text-neutral-500 hover:text-white disabled:opacity-30"
|
||||
aria-label={`Move exercise ${index + 1} up`}
|
||||
data-testid={`move-up-${index}`}
|
||||
>
|
||||
<span className="block -rotate-90">‹</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleMove(index, "down")}
|
||||
disabled={disabled || index === value.length - 1}
|
||||
className="text-neutral-500 hover:text-white disabled:opacity-30"
|
||||
aria-label={`Move exercise ${index + 1} down`}
|
||||
data-testid={`move-down-${index}`}
|
||||
>
|
||||
<span className="block rotate-90">‹</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 gap-3">
|
||||
<div className="flex-1">
|
||||
<label className="mb-1 block text-xs text-neutral-500">
|
||||
Exercise Name
|
||||
</label>
|
||||
<Input
|
||||
value={exercise.name}
|
||||
onChange={(e) => handleUpdate(index, "name", e.target.value)}
|
||||
placeholder="e.g., Jumping Jacks"
|
||||
disabled={disabled}
|
||||
className="h-9"
|
||||
data-testid={`exercise-name-${index}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-24">
|
||||
<label className="mb-1 block text-xs text-neutral-500">
|
||||
Duration
|
||||
</label>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
value={exercise.duration}
|
||||
onChange={(e) =>
|
||||
handleUpdate(index, "duration", parseInt(e.target.value) || 0)
|
||||
}
|
||||
disabled={disabled}
|
||||
className="h-9"
|
||||
min={1}
|
||||
data-testid={`exercise-duration-${index}`}
|
||||
/>
|
||||
<span className="text-sm text-neutral-500">s</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemove(index)}
|
||||
disabled={disabled || value.length <= 1}
|
||||
className="text-neutral-500 hover:text-red-500"
|
||||
aria-label={`Delete exercise ${index + 1}`}
|
||||
data-testid={`delete-exercise-${index}`}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleAdd}
|
||||
disabled={disabled}
|
||||
className="w-full border-dashed border-neutral-700 text-neutral-400 hover:border-orange-500 hover:text-orange-500"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Exercise
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { ExerciseList }
|
||||
export type { Exercise }
|
||||
248
admin-web/components/tag-input.test.tsx
Normal file
248
admin-web/components/tag-input.test.tsx
Normal file
@@ -0,0 +1,248 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { TagInput } from './tag-input'
|
||||
|
||||
describe('TagInput', () => {
|
||||
const mockOnChange = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should render empty input with placeholder', () => {
|
||||
render(
|
||||
<TagInput
|
||||
value={[]}
|
||||
onChange={mockOnChange}
|
||||
placeholder="Add equipment..."
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByPlaceholderText('Add equipment...')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should add tag on Enter key', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TagInput
|
||||
value={[]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'Dumbbells')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells'])
|
||||
})
|
||||
|
||||
it('should add tag on blur', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TagInput
|
||||
value={[]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'Yoga Mat')
|
||||
await user.tab() // blur
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['Yoga Mat'])
|
||||
})
|
||||
|
||||
it('should not add empty tag', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TagInput
|
||||
value={[]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, ' ')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not add duplicate tags', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TagInput
|
||||
value={['Dumbbells']}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'Dumbbells')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should remove tag on X click', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TagInput
|
||||
value={['Dumbbells', 'Yoga Mat']}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
// Find and click the remove button on first tag
|
||||
const removeButtons = screen.getAllByRole('button', { name: '' })
|
||||
await user.click(removeButtons[0])
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['Yoga Mat'])
|
||||
})
|
||||
|
||||
it('should remove last tag on Backspace when input empty', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TagInput
|
||||
value={['Dumbbells', 'Yoga Mat']}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.click(input)
|
||||
await user.keyboard('{Backspace}')
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells'])
|
||||
})
|
||||
|
||||
it('should not remove tag on Backspace when input has text', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TagInput
|
||||
value={['Dumbbells']}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, 'Yoga')
|
||||
await user.keyboard('{Backspace}')
|
||||
|
||||
expect(mockOnChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should display all tags', () => {
|
||||
render(
|
||||
<TagInput
|
||||
value={['Dumbbells', 'Yoga Mat', 'Resistance Band']}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Dumbbells')).toBeInTheDocument()
|
||||
expect(screen.getByText('Yoga Mat')).toBeInTheDocument()
|
||||
expect(screen.getByText('Resistance Band')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should trim whitespace from tags', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TagInput
|
||||
value={[]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
await user.type(input, ' Dumbbells ')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells'])
|
||||
})
|
||||
|
||||
it('should respect disabled prop', () => {
|
||||
render(
|
||||
<TagInput
|
||||
value={['Dumbbells']}
|
||||
onChange={mockOnChange}
|
||||
disabled
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeDisabled()
|
||||
// Remove button on tag should not be present when disabled
|
||||
const removeButtons = screen.queryAllByRole('button', { name: '' })
|
||||
expect(removeButtons).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('should clear input after adding tag', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(
|
||||
<TagInput
|
||||
value={[]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement
|
||||
await user.type(input, 'Dumbbells')
|
||||
await user.keyboard('{Enter}')
|
||||
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
|
||||
it('should handle multiple tags addition', async () => {
|
||||
const user = userEvent.setup()
|
||||
const { rerender } = render(
|
||||
<TagInput
|
||||
value={[]}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
const input = screen.getByRole('textbox')
|
||||
|
||||
// Add first tag
|
||||
await user.type(input, 'Dumbbells')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells'])
|
||||
|
||||
// Rerender with updated value
|
||||
rerender(
|
||||
<TagInput
|
||||
value={['Dumbbells']}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
// Add second tag
|
||||
await user.type(input, 'Yoga Mat')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells', 'Yoga Mat'])
|
||||
|
||||
// Rerender with updated value
|
||||
rerender(
|
||||
<TagInput
|
||||
value={['Dumbbells', 'Yoga Mat']}
|
||||
onChange={mockOnChange}
|
||||
/>
|
||||
)
|
||||
|
||||
// Add third tag
|
||||
await user.type(input, 'Resistance Band')
|
||||
await user.keyboard('{Enter}')
|
||||
expect(mockOnChange).toHaveBeenCalledWith(['Dumbbells', 'Yoga Mat', 'Resistance Band'])
|
||||
})
|
||||
})
|
||||
100
admin-web/components/tag-input.tsx
Normal file
100
admin-web/components/tag-input.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { X, Plus } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Input } from "@/components/ui/input"
|
||||
|
||||
interface TagInputProps {
|
||||
value: string[]
|
||||
onChange: (value: string[]) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
id?: string
|
||||
}
|
||||
|
||||
function TagInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "Add tag...",
|
||||
className,
|
||||
disabled = false,
|
||||
}: TagInputProps) {
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
const inputRef = React.useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleAddTag = () => {
|
||||
const trimmed = inputValue.trim()
|
||||
if (trimmed && !value.includes(trimmed)) {
|
||||
onChange([...value, trimmed])
|
||||
setInputValue("")
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
onChange(value.filter((tag) => tag !== tagToRemove))
|
||||
}
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault()
|
||||
handleAddTag()
|
||||
} else if (e.key === "Backspace" && !inputValue && value.length > 0) {
|
||||
handleRemoveTag(value[value.length - 1])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2 rounded-md border border-neutral-700 bg-neutral-900 p-2 focus-within:border-orange-500 focus-within:ring-2 focus-within:ring-orange-500 focus-within:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{value.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 rounded-md bg-orange-500/20 px-2 py-1 text-sm text-orange-500"
|
||||
>
|
||||
{tag}
|
||||
{!disabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="rounded-full p-0.5 hover:bg-orange-500/30"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
<div className="flex flex-1 items-center">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleAddTag}
|
||||
placeholder={value.length === 0 ? placeholder : ""}
|
||||
disabled={disabled}
|
||||
className="flex-1 border-0 bg-transparent px-0 py-1 text-white placeholder:text-neutral-500 focus-visible:ring-0 focus-visible:ring-offset-0"
|
||||
/>
|
||||
{inputValue.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAddTag}
|
||||
disabled={disabled}
|
||||
className="ml-2 rounded-md p-1 text-neutral-500 hover:bg-neutral-800 hover:text-white"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export { TagInput }
|
||||
Reference in New Issue
Block a user