@@ -26,7 +26,7 @@ export interface TagInputStyleClassesProps {
2626
2727export interface TagInputProps
2828 extends OmittedInputProps ,
29- VariantProps < typeof tagVariants > {
29+ VariantProps < typeof tagVariants > {
3030 placeholder ?: string ;
3131 tags : Tag [ ] ;
3232 setTags : React . Dispatch < React . SetStateAction < Tag [ ] > > ;
@@ -93,7 +93,6 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
9393 } = props ;
9494
9595 const [ inputValue , setInputValue ] = React . useState ( "" ) ;
96- const [ tagCount , setTagCount ] = React . useState ( Math . max ( 0 , tags . length ) ) ;
9796 const inputRef = React . useRef < HTMLInputElement > ( null ) ;
9897
9998 if (
@@ -111,72 +110,127 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
111110 onInputChange ?.( newValue ) ;
112111 } ;
113112
113+ const tryAddTag =
114+ ( rawText : string , nextTags : Tag [ ] ) => {
115+ const newTagText = rawText . trim ( ) ;
116+ if ( ! newTagText ) {
117+ return nextTags ;
118+ }
119+ if ( ! allowDuplicates && nextTags . some ( ( tag ) => tag . text === newTagText ) ) {
120+ return nextTags ;
121+ }
122+ if ( maxTags !== undefined && nextTags . length >= maxTags ) {
123+ return nextTags ;
124+ }
125+ const newTagId = crypto . randomUUID ( ) ;
126+ onTagAdd ?.( newTagText ) ;
127+ return [ ...nextTags , { id : newTagId , text : newTagText } ] ;
128+ } ;
129+
130+ const escapeForCharClass = ( value : string ) =>
131+ value . replace ( / [ - \\ ^ $ * + ? . ( ) | [ \] { } ] / g, "\\$&" ) ;
132+
133+ const commitInputValue =
134+ ( rawText : string , splitByDelimiters : boolean ) => {
135+ const trimmed = rawText . trim ( ) ;
136+ if ( ! trimmed ) {
137+ setInputValue ( "" ) ;
138+ return ;
139+ }
140+
141+ const charDelimiters = delimiterList . filter ( ( d ) => d . length === 1 ) ;
142+ const nextTagTexts =
143+ splitByDelimiters && charDelimiters . length
144+ ? trimmed
145+ . split (
146+ new RegExp (
147+ `[${ charDelimiters . map ( escapeForCharClass ) . join ( "" ) } ]+` ,
148+ ) ,
149+ )
150+ . map ( ( t ) => t . trim ( ) )
151+ . filter ( Boolean )
152+ : [ trimmed ] ;
153+
154+ // Use functional updater pattern to get latest state and avoid race conditions
155+ setTags ( ( prevTags ) => {
156+ let nextTags = prevTags ;
157+ for ( const text of nextTagTexts ) {
158+ nextTags = tryAddTag ( text , nextTags ) ;
159+ }
160+ if ( nextTags !== prevTags ) {
161+ return nextTags ;
162+ }
163+ return prevTags ;
164+ } ) ;
165+ setInputValue ( "" ) ;
166+ } ;
167+
114168 const handleInputFocus = ( event : React . FocusEvent < HTMLInputElement > ) => {
115169 setActiveTagIndex ( null ) ; // Reset active tag index when the input field gains focus
116170 onFocus ?.( event ) ;
117171 } ;
118172
119173 const handleInputBlur = ( event : React . FocusEvent < HTMLInputElement > ) => {
174+ // If the user pasted/typed text and clicks outside (e.g. submit button),
175+ // ensure the pending input becomes a tag.
176+ commitInputValue ( inputValue , true ) ;
120177 onBlur ?.( event ) ;
121178 } ;
122179
123180 const handleKeyDown = ( e : React . KeyboardEvent < HTMLInputElement > ) => {
124181 if ( delimiterList . includes ( e . key ) ) {
125182 e . preventDefault ( ) ;
126- const newTagText = inputValue . trim ( ) ;
127-
128- const newTagId = crypto . randomUUID ( ) ;
129-
130- if (
131- newTagText &&
132- ( allowDuplicates || ! tags . some ( ( tag ) => tag . text === newTagText ) ) &&
133- ( maxTags === undefined || tags . length < maxTags )
134- ) {
135- setTags ( [ ...tags , { id : newTagId , text : newTagText } ] ) ;
136- onTagAdd ?.( newTagText ) ;
137- setTagCount ( ( prevTagCount ) => prevTagCount + 1 ) ;
138- }
139- setInputValue ( "" ) ;
140- } else {
141- switch ( e . key ) {
142- case "Backspace" :
143- if ( e . currentTarget . value === "" ) {
144- e . preventDefault ( ) ;
145- const newTags = [ ...tags ] ;
146- newTags . splice ( tagCount - 1 , 1 ) ;
147- setTags ( newTags ) ;
148- setTagCount ( newTags . length ) ;
149- }
150- break ;
151- }
183+ commitInputValue ( inputValue , false ) ;
184+ } else if ( e . key === "Backspace" && inputValue . length === 0 ) {
185+ setTags ( ( prevTags ) => {
186+ if ( prevTags . length > 0 ) {
187+ const removedTag = prevTags [ prevTags . length - 1 ] ;
188+ const newTags = prevTags . slice ( 0 , - 1 ) ;
189+ onTagRemove ?.( removedTag . text ) ;
190+ return newTags ;
191+ }
192+ return prevTags ;
193+ } ) ;
194+ e . preventDefault ( ) ;
152195 }
153196 } ;
154197
198+ const handlePaste = ( e : React . ClipboardEvent < HTMLInputElement > ) => {
199+ e . preventDefault ( ) ;
200+ const pastedText = e . clipboardData . getData ( "text/plain" ) ;
201+ commitInputValue ( pastedText , true ) ;
202+ } ;
203+
155204 const removeTag = ( idToRemove : string ) => {
156- setTags ( tags . filter ( ( tag ) => tag . id !== idToRemove ) ) ;
157- onTagRemove ?.( tags . find ( ( tag ) => tag . id === idToRemove ) ?. text || "" ) ;
158- setTagCount ( ( prevTagCount ) => prevTagCount - 1 ) ;
205+ setTags ( ( prevTags ) => {
206+ const tagToRemove = prevTags . find ( ( tag ) => tag . id === idToRemove ) ;
207+ const newTags = prevTags . filter ( ( tag ) => tag . id !== idToRemove ) ;
208+ if ( newTags . length !== prevTags . length ) {
209+ onTagRemove ?.( tagToRemove ?. text || "" ) ;
210+ return newTags ;
211+ }
212+ return prevTags ;
213+ } ) ;
159214 } ;
160215
161216 const truncatedTags = truncate
162217 ? tags . map ( ( tag ) => ( {
163- id : tag . id ,
164- text :
165- tag . text ?. length > truncate
166- ? `${ tag . text . substring ( 0 , truncate ) } ...`
167- : tag . text ,
168- } ) )
218+ id : tag . id ,
219+ text :
220+ tag . text ?. length > truncate
221+ ? `${ tag . text . substring ( 0 , truncate ) } ...`
222+ : tag . text ,
223+ } ) )
169224 : tags ;
170225
171226 return (
172227 < div
173- className = { `flex w-full ${
174- inputFieldPosition === "bottom"
175- ? "flex-col"
176- : inputFieldPosition === "top"
228+ className = { `flex w-full ${ inputFieldPosition === "bottom"
229+ ? "flex-col"
230+ : inputFieldPosition === "top"
177231 ? "flex-col-reverse"
178232 : "flex-row"
179- } `}
233+ } `}
180234 >
181235 < div className = "w-full" >
182236 < div
@@ -220,6 +274,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
220274 onKeyDown = { handleKeyDown }
221275 onFocus = { handleInputFocus }
222276 onBlur = { handleInputBlur }
277+ onPaste = { handlePaste }
223278 { ...inputProps }
224279 className = { cn (
225280 "h-5 w-fit flex-1 border-0 bg-transparent px-1.5 focus-visible:ring-0 focus-visible:ring-transparent focus-visible:ring-offset-0" ,
@@ -235,7 +290,7 @@ const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>(
235290 { showCount && maxTags && (
236291 < div className = "flex" >
237292 < span className = "ml-auto mt-1 text-sm text-muted-foreground" >
238- { `${ tagCount } ` } /{ `${ maxTags } ` }
293+ { `${ tags . length } ` } /{ `${ maxTags } ` }
239294 </ span >
240295 </ div >
241296 ) }
0 commit comments