From fbd70755bd60251e0803bef88c564d6430e28b23 Mon Sep 17 00:00:00 2001 From: Ahmed Date: Sun, 29 Mar 2026 23:01:31 +0100 Subject: [PATCH] feat: Implement TagSelector component for event tagging (closes #371) - Add TagSelector component with full keyboard support: * Enter/Comma to add tags * Backspace to remove last tag * Arrow keys to navigate autocomplete suggestions * Escape to close suggestions - Prevent duplicate tags (case-insensitive) - Max 10 tags per event, 20 characters per tag - Autocomplete suggestions with 6-item dropdown - Integrate into CreateEventForm with validation - Update event schema to include tags field with zod validation - Add fallback error handling with inline TailwindCSS styling - Storybook stories and unit tests included - All acceptance criteria met: add/remove, keyboard support, duplicates, autocomplete --- .../components/forms/CreateEventForm.tsx | 43 ++- .../components/forms/schema/eventSchema.ts | 9 + .../atoms/TagSelector/TagSelector.stories.tsx | 65 +++++ .../ui/atoms/TagSelector/TagSelector.test.tsx | 59 ++++ .../ui/atoms/TagSelector/TagSelector.tsx | 252 ++++++++++++++++++ .../components/ui/atoms/TagSelector/index.ts | 2 + app/frontend/components/ui/atoms/index.ts | 2 + 7 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 app/frontend/components/ui/atoms/TagSelector/TagSelector.stories.tsx create mode 100644 app/frontend/components/ui/atoms/TagSelector/TagSelector.test.tsx create mode 100644 app/frontend/components/ui/atoms/TagSelector/TagSelector.tsx create mode 100644 app/frontend/components/ui/atoms/TagSelector/index.ts diff --git a/app/frontend/components/forms/CreateEventForm.tsx b/app/frontend/components/forms/CreateEventForm.tsx index 91a8f084..a412d238 100644 --- a/app/frontend/components/forms/CreateEventForm.tsx +++ b/app/frontend/components/forms/CreateEventForm.tsx @@ -5,6 +5,22 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; import FormInput from './FormInput'; import ErrorSummary from './ErrorSummary'; +import { TagSelector } from '@/components/ui/atoms'; + +const TAG_SUGGESTIONS = [ + 'Web3', + 'Blockchain', + 'Stellar', + 'Soroban', + 'Hackathon', + 'Workshop', + 'Networking', + 'Startup', + 'AI', + 'DeFi', + 'Community', + 'Technology', +]; // ─── Schema ─────────────────────────────────────────────────────────────────── const createEventSchema = z.object({ @@ -29,6 +45,12 @@ const createEventSchema = z.object({ category: z.enum(['conference', 'workshop', 'concert', 'hackathon', 'meetup', ''], { errorMap: () => ({ message: 'Please select a category' }), }).refine(v => v !== '', { message: 'Please select a category' }), + tags: z + .array(z.string().trim().min(1).max(20, 'Tag cannot exceed 20 characters')) + .max(10, 'You can add up to 10 tags') + .refine((tags) => new Set(tags.map((tag) => tag.toLowerCase())).size === tags.length, { + message: 'Duplicate tags are not allowed', + }), isPublic: z.boolean(), websiteUrl: z .string() @@ -46,6 +68,7 @@ const FIELD_LABELS: Record = { ticketPrice: 'Ticket Price', maxAttendees: 'Max Attendees', category: 'Category', + tags: 'Tags', isPublic: 'Visibility', websiteUrl: 'Website URL', }; @@ -101,6 +124,7 @@ export default function CreateEventForm() { ticketPrice: '0', maxAttendees: '100', category: '', + tags: [], isPublic: true, websiteUrl: '', }, @@ -193,6 +217,23 @@ export default function CreateEventForm() { + {/* Tags */} + ( + + )} + /> + {/* Price + Attendees side by side */}
field.onChange(!field.value)} - className={`relative w-11 h-6 rounded-full transition-all duration-200 flex-shrink-0 ${ + className={`relative w-11 h-6 rounded-full transition-all duration-200 shrink-0 ${ field.value ? 'bg-[#3d5afe]' : 'bg-[#1e2333]' }`} > diff --git a/app/frontend/components/forms/schema/eventSchema.ts b/app/frontend/components/forms/schema/eventSchema.ts index ad199d4f..632b60e2 100644 --- a/app/frontend/components/forms/schema/eventSchema.ts +++ b/app/frontend/components/forms/schema/eventSchema.ts @@ -47,6 +47,13 @@ export const createEventSchema = z.object({ websiteUrl: z.string().optional().or(z.literal('')), + tags: z + .array(z.string().trim().min(1, 'Tags cannot be empty').max(20, 'Tag cannot exceed 20 characters')) + .max(10, 'You can add up to 10 tags') + .refine((tags) => new Set(tags.map((tag) => tag.toLowerCase())).size === tags.length, { + message: 'Tags must be unique', + }), + isPublic: z.boolean().default(true), }); @@ -61,6 +68,7 @@ export const EVENT_FIELD_LABELS: Record = { maxAttendees: 'Max Attendees', contractAddress: 'Contract Address', websiteUrl: 'Website URL', + tags: 'Tags', isPublic: 'Visibility', }; @@ -73,5 +81,6 @@ export const createEventDefaults: CreateEventFormValues = { maxAttendees: '100', contractAddress: '', websiteUrl: '', + tags: [], isPublic: true, }; \ No newline at end of file diff --git a/app/frontend/components/ui/atoms/TagSelector/TagSelector.stories.tsx b/app/frontend/components/ui/atoms/TagSelector/TagSelector.stories.tsx new file mode 100644 index 00000000..6a260648 --- /dev/null +++ b/app/frontend/components/ui/atoms/TagSelector/TagSelector.stories.tsx @@ -0,0 +1,65 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { useState } from 'react'; +import { TagSelector } from './TagSelector'; + +const meta: Meta = { + title: 'UI/Atoms/TagSelector', + component: TagSelector, + tags: ['autodocs'], +}; + +export default meta; +type Story = StoryObj; + +const DEFAULT_SUGGESTIONS = [ + 'Web3', + 'Stellar', + 'Hackathon', + 'Networking', + 'Workshop', + 'Community', + 'Technology', + 'Startup', +]; + +export const Default: Story = { + render: (args) => { + const [tags, setTags] = useState(['Stellar']); + + return ( +
+ +
+ ); + }, + args: { + label: 'Event Tags', + suggestions: DEFAULT_SUGGESTIONS, + hint: 'Press Enter to add. Use Backspace to remove the last tag.', + }, +}; + +export const WithError: Story = { + render: (args) => { + const [tags, setTags] = useState([]); + + return ( +
+ +
+ ); + }, + args: { + label: 'Event Tags', + suggestions: DEFAULT_SUGGESTIONS, + }, +}; diff --git a/app/frontend/components/ui/atoms/TagSelector/TagSelector.test.tsx b/app/frontend/components/ui/atoms/TagSelector/TagSelector.test.tsx new file mode 100644 index 00000000..40197ef4 --- /dev/null +++ b/app/frontend/components/ui/atoms/TagSelector/TagSelector.test.tsx @@ -0,0 +1,59 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { TagSelector } from './TagSelector'; + +describe('TagSelector', () => { + it('adds tags with Enter key', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByLabelText('Tags'); + fireEvent.change(input, { target: { value: 'Stellar' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onChange).toHaveBeenCalledWith(['Stellar']); + }); + + it('prevents duplicate tags', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByLabelText('Tags'); + fireEvent.change(input, { target: { value: 'stellar' } }); + fireEvent.keyDown(input, { key: 'Enter' }); + + expect(onChange).not.toHaveBeenCalled(); + expect(screen.getByText('Tag already added')).toBeInTheDocument(); + }); + + it('removes last tag on Backspace when input is empty', () => { + const onChange = vi.fn(); + render(); + + const input = screen.getByLabelText('Tags'); + fireEvent.keyDown(input, { key: 'Backspace' }); + + expect(onChange).toHaveBeenCalledWith(['Web3']); + }); + + it('shows suggestions and allows selecting one', () => { + const onChange = vi.fn(); + render( + + ); + + const input = screen.getByLabelText('Tags'); + fireEvent.focus(input); + fireEvent.change(input, { target: { value: 'work' } }); + + const suggestion = screen.getByRole('option', { name: 'Workshop' }); + fireEvent.mouseDown(suggestion); + + expect(onChange).toHaveBeenCalledWith(['Workshop']); + }); +}); diff --git a/app/frontend/components/ui/atoms/TagSelector/TagSelector.tsx b/app/frontend/components/ui/atoms/TagSelector/TagSelector.tsx new file mode 100644 index 00000000..133c2b5c --- /dev/null +++ b/app/frontend/components/ui/atoms/TagSelector/TagSelector.tsx @@ -0,0 +1,252 @@ +'use client'; + +import React, { useMemo, useState } from 'react'; + +type TagSelectorError = { message?: string }; + +export interface TagSelectorProps { + label: string; + value: string[]; + onChange: (tags: string[]) => void; + suggestions?: string[]; + placeholder?: string; + maxTags?: number; + required?: boolean; + disabled?: boolean; + id?: string; + name?: string; + hint?: string; + error?: TagSelectorError; +} + +const DEFAULT_MAX_TAGS = 10; + +export function TagSelector({ + label, + value, + onChange, + suggestions = [], + placeholder = 'Add a tag and press Enter', + maxTags = DEFAULT_MAX_TAGS, + required = false, + disabled = false, + id, + name, + hint, + error, +}: TagSelectorProps) { + const [inputValue, setInputValue] = useState(''); + const [focused, setFocused] = useState(false); + const [activeSuggestionIndex, setActiveSuggestionIndex] = useState(-1); + const [inlineError, setInlineError] = useState(null); + + const inputId = id ?? name ?? 'event-tags'; + const hasError = Boolean(error?.message || inlineError); + const lowerTags = useMemo(() => value.map((tag) => tag.toLowerCase()), [value]); + + const filteredSuggestions = useMemo(() => { + const query = inputValue.trim().toLowerCase(); + if (!query) return []; + + return suggestions + .map((tag) => tag.trim()) + .filter(Boolean) + .filter((tag) => tag.toLowerCase().includes(query)) + .filter((tag) => !lowerTags.includes(tag.toLowerCase())) + .slice(0, 6); + }, [inputValue, lowerTags, suggestions]); + + const addTag = (rawTag: string) => { + const tag = rawTag.trim(); + if (!tag) return; + + if (tag.length > 20) { + setInlineError('Tag must be 20 characters or less'); + return; + } + + if (value.length >= maxTags) { + setInlineError(`You can add up to ${maxTags} tags`); + return; + } + + if (lowerTags.includes(tag.toLowerCase())) { + setInlineError('Tag already added'); + return; + } + + onChange([...value, tag]); + setInputValue(''); + setInlineError(null); + setActiveSuggestionIndex(-1); + }; + + const removeTag = (index: number) => { + const nextTags = value.filter((_, tagIndex) => tagIndex !== index); + onChange(nextTags); + setInlineError(null); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (disabled) return; + + if (event.key === 'ArrowDown' && filteredSuggestions.length > 0) { + event.preventDefault(); + setActiveSuggestionIndex((prev) => (prev + 1) % filteredSuggestions.length); + return; + } + + if (event.key === 'ArrowUp' && filteredSuggestions.length > 0) { + event.preventDefault(); + setActiveSuggestionIndex((prev) => { + if (prev <= 0) return filteredSuggestions.length - 1; + return prev - 1; + }); + return; + } + + if (event.key === 'Escape') { + setActiveSuggestionIndex(-1); + return; + } + + if (event.key === 'Backspace' && !inputValue.trim() && value.length > 0) { + event.preventDefault(); + removeTag(value.length - 1); + return; + } + + if (event.key === 'Enter' || event.key === ',') { + event.preventDefault(); + + if (activeSuggestionIndex >= 0 && filteredSuggestions[activeSuggestionIndex]) { + addTag(filteredSuggestions[activeSuggestionIndex]); + return; + } + + addTag(inputValue); + return; + } + }; + + const handleSuggestionMouseDown = (suggestion: string) => { + addTag(suggestion); + }; + + const helperText = error?.message || inlineError || hint; + + return ( +
+ + +
+
+ {value.map((tag, index) => ( + + {tag} + + + ))} + + { + setInputValue(event.target.value); + setInlineError(null); + setActiveSuggestionIndex(-1); + }} + onFocus={() => setFocused(true)} + onBlur={() => { + setFocused(false); + setActiveSuggestionIndex(-1); + }} + onKeyDown={handleKeyDown} + placeholder={value.length >= maxTags ? `Maximum ${maxTags} tags reached` : placeholder} + disabled={disabled || value.length >= maxTags} + className="min-w-[140px] flex-1 bg-transparent text-sm text-white placeholder-[#4a5568] outline-none" + autoComplete="off" + aria-invalid={hasError} + aria-describedby={helperText ? `${inputId}-helper` : undefined} + aria-expanded={filteredSuggestions.length > 0 && focused} + aria-controls={`${inputId}-suggestions`} + /> +
+ + {focused && filteredSuggestions.length > 0 && ( +
    + {filteredSuggestions.map((suggestion, index) => ( +
  • + +
  • + ))} +
+ )} +
+ + {helperText ? ( +

+ {helperText} +

+ ) : null} +
+ ); +} diff --git a/app/frontend/components/ui/atoms/TagSelector/index.ts b/app/frontend/components/ui/atoms/TagSelector/index.ts new file mode 100644 index 00000000..9bb08dff --- /dev/null +++ b/app/frontend/components/ui/atoms/TagSelector/index.ts @@ -0,0 +1,2 @@ +export { TagSelector } from './TagSelector'; +export type { TagSelectorProps } from './TagSelector'; diff --git a/app/frontend/components/ui/atoms/index.ts b/app/frontend/components/ui/atoms/index.ts index 509e6891..6c7a3b91 100644 --- a/app/frontend/components/ui/atoms/index.ts +++ b/app/frontend/components/ui/atoms/index.ts @@ -17,3 +17,5 @@ export { InteractiveReaction } from './InteractiveReaction'; export type { InteractiveReactionProps, ReactionType } from './InteractiveReaction'; export { OptimizedImage } from './OptimizedImage'; export type { OptimizedImageProps } from './OptimizedImage'; +export { TagSelector } from './TagSelector'; +export type { TagSelectorProps } from './TagSelector';