invite people modal

This commit is contained in:
Nate Kelley 2025-04-21 12:59:41 -06:00
parent dd8e1c98cd
commit 1892f812f0
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
3 changed files with 98 additions and 21 deletions

View File

@ -51,10 +51,14 @@ export const InvitePeopleModal: React.FC<{
tags={emails} tags={emails}
onChangeText={setInputText} onChangeText={setInputText}
onTagAdd={(v) => { onTagAdd={(v) => {
if (validate(v)) { const arrayedTags = Array.isArray(v) ? v : [v];
setEmails([...emails, v]); const hadMultipleTags = arrayedTags.length > 1;
} else { const validTags = arrayedTags.filter((tag) => validate(tag));
openErrorMessage(`Invalid email - ${v}`);
setEmails([...emails, ...validTags]);
if (validTags.length !== arrayedTags.length) {
openErrorMessage(hadMultipleTags ? 'List contained invalid emails' : 'Invalid email');
} }
}} }}
onTagRemove={(index) => { onTagRemove={(index) => {

View File

@ -26,6 +26,13 @@ const meta: Meta<typeof InputTagInput> = {
}, },
maxTags: { maxTags: {
control: 'number' control: 'number'
},
delimiter: {
control: 'text',
description: 'Character used to separate tags (default is comma)',
table: {
defaultValue: { summary: ',' }
}
} }
} }
}; };
@ -37,8 +44,13 @@ type Story = StoryObj<typeof InputTagInput>;
const InteractiveTagInput = (args: React.ComponentProps<typeof InputTagInput>) => { const InteractiveTagInput = (args: React.ComponentProps<typeof InputTagInput>) => {
const [tags, setTags] = useState<string[]>(args.tags || []); const [tags, setTags] = useState<string[]>(args.tags || []);
const handleTagAdd = (tag: string) => { const handleTagAdd = (tag: string | string[]) => {
console.log('tag', tag);
if (Array.isArray(tag)) {
setTags([...tags, ...tag]);
} else {
setTags([...tags, tag]); setTags([...tags, tag]);
}
}; };
const handleTagRemove = (index: number) => { const handleTagRemove = (index: number) => {
@ -112,3 +124,39 @@ export const Empty: Story = {
tags: [] tags: []
} }
}; };
export const CustomDelimiter: Story = {
render: (args) => <InteractiveTagInput {...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) => <InteractiveTagInput {...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.'
}
}
}
};

View File

@ -9,13 +9,14 @@ import { InputTag } from './InputTag';
export interface TagInputProps extends VariantProps<typeof inputVariants> { export interface TagInputProps extends VariantProps<typeof inputVariants> {
tags: string[]; tags: string[];
onTagAdd?: (tag: string) => void; onTagAdd?: (tag: string | string[]) => void;
onTagRemove?: (index: number) => void; onTagRemove?: (index: number) => void;
onChangeText?: (text: string) => void; onChangeText?: (text: string) => void;
placeholder?: string; placeholder?: string;
disabled?: boolean; disabled?: boolean;
maxTags?: number; maxTags?: number;
className?: string; className?: string;
delimiter?: string;
} }
const InputTagInput = React.forwardRef<HTMLInputElement, TagInputProps>( const InputTagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
@ -31,6 +32,7 @@ const InputTagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
placeholder, placeholder,
disabled = false, disabled = false,
maxTags, maxTags,
delimiter = ',',
...props ...props
}, },
ref ref
@ -39,29 +41,42 @@ const InputTagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
const containerRef = React.useRef<HTMLDivElement>(null); const containerRef = React.useRef<HTMLDivElement>(null);
const scrollRef = React.useRef<HTMLDivElement>(null); const scrollRef = React.useRef<HTMLDivElement>(null);
const addTag = useMemoizedFn((value: string) => { const addMultipleTags = useMemoizedFn((value: string) => {
const newTag = value.trim(); const newTags = value
if (newTag !== '' && !tags.includes(newTag)) { .split(delimiter)
if (maxTags && tags.length >= maxTags) return; .map((tag) => tag.trim())
onTagAdd?.(newTag); .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(''); setInputValue('');
// Scroll to the end after adding a new tag
requestAnimationFrame(() => {
if (scrollRef.current) { if (scrollRef.current) {
scrollRef.current.scrollLeft = scrollRef.current.scrollWidth; scrollRef.current.scrollLeft = scrollRef.current.scrollWidth;
} }
} });
}); });
const handleBlur = useMemoizedFn(() => { const handleBlur = useMemoizedFn(() => {
if (inputValue.trim() !== '') { if (inputValue.trim() !== '') {
addTag(inputValue); addMultipleTags(inputValue);
} }
}); });
const handleKeyDown = useMemoizedFn((e: React.KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = useMemoizedFn((e: React.KeyboardEvent<HTMLInputElement>) => {
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(); e.preventDefault();
addTag(inputValue); addMultipleTags(inputValue);
} else if (e.key === 'Backspace' && inputValue === '' && tags.length > 0 && !disabled) { } else if (e.key === 'Backspace' && inputValue === '' && tags.length > 0 && !disabled) {
onTagRemove?.(tags.length - 1); onTagRemove?.(tags.length - 1);
} }
@ -69,12 +84,21 @@ const InputTagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
const handleInputChange = useMemoizedFn((e: React.ChangeEvent<HTMLInputElement>) => { const handleInputChange = useMemoizedFn((e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
if (value.endsWith(',')) { if (value.includes(delimiter)) {
addTag(value.slice(0, -1)); addMultipleTags(value);
} else { } else {
setInputValue(value); setInputValue(value);
}
onChangeText?.(value); onChangeText?.(value);
}
});
const handlePaste = useMemoizedFn((e: React.ClipboardEvent<HTMLInputElement>) => {
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 // Focus the container when clicked
@ -124,6 +148,7 @@ const InputTagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
onChange={handleInputChange} onChange={handleInputChange}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onBlur={handleBlur} 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" 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} placeholder={tags.length === 0 ? placeholder : undefined}
disabled={isDisabledInput} disabled={isDisabledInput}