From 1892f812f0936d5acdde51717818bc2ff5e5a6ea Mon Sep 17 00:00:00 2001 From: Nate Kelley Date: Mon, 21 Apr 2025 12:59:41 -0600 Subject: [PATCH] invite people modal --- .../features/modal/InvitePeopleModal.tsx | 12 ++-- .../ui/inputs/InputTagInput.stories.tsx | 52 +++++++++++++++++- .../components/ui/inputs/InputTagInput.tsx | 55 ++++++++++++++----- 3 files changed, 98 insertions(+), 21 deletions(-) diff --git a/web/src/components/features/modal/InvitePeopleModal.tsx b/web/src/components/features/modal/InvitePeopleModal.tsx index 658c36f88..f10e39515 100644 --- a/web/src/components/features/modal/InvitePeopleModal.tsx +++ b/web/src/components/features/modal/InvitePeopleModal.tsx @@ -51,10 +51,14 @@ export const InvitePeopleModal: React.FC<{ tags={emails} onChangeText={setInputText} onTagAdd={(v) => { - if (validate(v)) { - setEmails([...emails, v]); - } else { - openErrorMessage(`Invalid email - ${v}`); + const arrayedTags = Array.isArray(v) ? v : [v]; + const hadMultipleTags = arrayedTags.length > 1; + const validTags = arrayedTags.filter((tag) => validate(tag)); + + setEmails([...emails, ...validTags]); + + if (validTags.length !== arrayedTags.length) { + openErrorMessage(hadMultipleTags ? 'List contained invalid emails' : 'Invalid email'); } }} onTagRemove={(index) => { diff --git a/web/src/components/ui/inputs/InputTagInput.stories.tsx b/web/src/components/ui/inputs/InputTagInput.stories.tsx index 8b82b961c..6362a736c 100644 --- a/web/src/components/ui/inputs/InputTagInput.stories.tsx +++ b/web/src/components/ui/inputs/InputTagInput.stories.tsx @@ -26,6 +26,13 @@ const meta: Meta = { }, maxTags: { control: 'number' + }, + delimiter: { + control: 'text', + description: 'Character used to separate tags (default is comma)', + table: { + defaultValue: { summary: ',' } + } } } }; @@ -37,8 +44,13 @@ type Story = StoryObj; const InteractiveTagInput = (args: React.ComponentProps) => { const [tags, setTags] = useState(args.tags || []); - const handleTagAdd = (tag: string) => { - setTags([...tags, tag]); + const handleTagAdd = (tag: string | string[]) => { + console.log('tag', tag); + if (Array.isArray(tag)) { + setTags([...tags, ...tag]); + } else { + setTags([...tags, tag]); + } }; const handleTagRemove = (index: number) => { @@ -112,3 +124,39 @@ export const Empty: Story = { tags: [] } }; + +export const CustomDelimiter: Story = { + render: (args) => , + args: { + placeholder: 'Type or paste "tag1;tag2;tag3"...', + delimiter: ';', + tags: [] + }, + parameters: { + docs: { + description: { + story: + 'Use a custom delimiter (semicolon in this example) to separate tags. You can:\n' + + '1. Type text and press the delimiter to create a tag\n' + + '2. Paste a delimited list (e.g., "tag1;tag2;tag3")\n' + + '3. Type or paste text with delimiters for automatic tag creation' + } + } + } +}; + +export const SpaceDelimiter: Story = { + render: (args) => , + args: { + placeholder: 'Type or paste space-separated tags...', + delimiter: ' ', + tags: [] + }, + parameters: { + docs: { + description: { + story: 'Using space as a delimiter. Perfect for handling space-separated lists of tags.' + } + } + } +}; diff --git a/web/src/components/ui/inputs/InputTagInput.tsx b/web/src/components/ui/inputs/InputTagInput.tsx index f6bd41703..666ae6acc 100644 --- a/web/src/components/ui/inputs/InputTagInput.tsx +++ b/web/src/components/ui/inputs/InputTagInput.tsx @@ -9,13 +9,14 @@ import { InputTag } from './InputTag'; export interface TagInputProps extends VariantProps { tags: string[]; - onTagAdd?: (tag: string) => void; + onTagAdd?: (tag: string | string[]) => void; onTagRemove?: (index: number) => void; onChangeText?: (text: string) => void; placeholder?: string; disabled?: boolean; maxTags?: number; className?: string; + delimiter?: string; } const InputTagInput = React.forwardRef( @@ -31,6 +32,7 @@ const InputTagInput = React.forwardRef( placeholder, disabled = false, maxTags, + delimiter = ',', ...props }, ref @@ -39,29 +41,42 @@ const InputTagInput = React.forwardRef( const containerRef = React.useRef(null); const scrollRef = React.useRef(null); - const addTag = useMemoizedFn((value: string) => { - const newTag = value.trim(); - if (newTag !== '' && !tags.includes(newTag)) { - if (maxTags && tags.length >= maxTags) return; - onTagAdd?.(newTag); - setInputValue(''); - // Scroll to the end after adding a new tag + const addMultipleTags = useMemoizedFn((value: string) => { + const newTags = value + .split(delimiter) + .map((tag) => tag.trim()) + .filter((tag) => tag !== '' && !tags.includes(tag)); + + if (maxTags) { + const availableSlots = maxTags - tags.length; + const validTags = newTags.slice(0, availableSlots); + onTagAdd?.(validTags); + } else { + onTagAdd?.(newTags); + } + + setInputValue(''); + + requestAnimationFrame(() => { if (scrollRef.current) { scrollRef.current.scrollLeft = scrollRef.current.scrollWidth; } - } + }); }); const handleBlur = useMemoizedFn(() => { if (inputValue.trim() !== '') { - addTag(inputValue); + addMultipleTags(inputValue); } }); const handleKeyDown = useMemoizedFn((e: React.KeyboardEvent) => { - if ((e.key === 'Tab' || e.key === 'Enter' || e.key === ',') && inputValue.trim() !== '') { + if ( + (e.key === 'Tab' || e.key === 'Enter' || e.key === delimiter) && + inputValue.trim() !== '' + ) { e.preventDefault(); - addTag(inputValue); + addMultipleTags(inputValue); } else if (e.key === 'Backspace' && inputValue === '' && tags.length > 0 && !disabled) { onTagRemove?.(tags.length - 1); } @@ -69,12 +84,21 @@ const InputTagInput = React.forwardRef( const handleInputChange = useMemoizedFn((e: React.ChangeEvent) => { const value = e.target.value; - if (value.endsWith(',')) { - addTag(value.slice(0, -1)); + if (value.includes(delimiter)) { + addMultipleTags(value); } else { setInputValue(value); + onChangeText?.(value); } - onChangeText?.(value); + }); + + const handlePaste = useMemoizedFn((e: React.ClipboardEvent) => { + const pastedText = e.clipboardData.getData('text'); + if (pastedText.includes(delimiter)) { + e.preventDefault(); + addMultipleTags(pastedText); + } + // If no delimiter is found, let the default paste behavior handle it }); // Focus the container when clicked @@ -124,6 +148,7 @@ const InputTagInput = React.forwardRef( onChange={handleInputChange} onKeyDown={handleKeyDown} onBlur={handleBlur} + onPaste={handlePaste} className="placeholder:text-gray-light min-w-[120px] flex-1 bg-transparent outline-none disabled:cursor-not-allowed disabled:opacity-50" placeholder={tags.length === 0 ? placeholder : undefined} disabled={isDisabledInput}