@@ -7,8 +7,6 @@ import { X } from 'react-feather';
77
88import { getArrayMaxLength , isArrayArg , isRequiredArg , isVectorArg } from '../lib/instruction-args' ;
99
10- const MAX_ARRAY_INPUTS = 100 ;
11-
1210export interface ArgumentInputProps extends React . ComponentProps < 'input' > {
1311 arg : ArgField ;
1412 error ?: { message ?: string | undefined } | undefined ;
@@ -30,45 +28,10 @@ interface ArrayArgumentInputProps extends Omit<ArgumentInputProps, 'ref'> {
3028}
3129const ArrayArgumentInput = forwardRef < HTMLInputElement , ArrayArgumentInputProps > (
3230 ( { arg, error, value, onChange, onBlur, inputId, ...props } , ref ) => {
33- const idCounterRef = useRef ( 0 ) ;
34- const stableIdsRef = useRef < string [ ] > ( [ ] ) ;
35- const inputRefsRef = useRef < Map < string , HTMLInputElement > > ( new Map ( ) ) ;
36-
37- const parseValues = ( val : string | number | readonly string [ ] | undefined ) : string [ ] => {
38- if ( ! val || typeof val !== 'string' ) return [ '' ] ;
39- const trimmed = val . trim ( ) ;
40- if ( trimmed === '' ) return [ '' ] ;
41- return trimmed . split ( ',' ) . map ( v => v . trim ( ) ) ;
42- } ;
43-
44- const values = parseValues ( value ) ;
45- const previousLengthRef = useRef ( values . length ) ;
46- const removedIndexRef = useRef < number | null > ( null ) ;
47-
48- // Add new IDs when array grows (removals are handled in handleRemoveItem)
49- if ( stableIdsRef . current . length < values . length ) {
50- const newIds = Array . from (
51- { length : values . length - stableIdsRef . current . length } ,
52- ( ) => `item-${ idCounterRef . current ++ } `
53- ) ;
54- stableIdsRef . current = [ ...stableIdsRef . current , ...newIds ] ;
55- }
56-
57- useEffect ( ( ) => {
58- const lengthDiff = values . length - previousLengthRef . current ;
59- if ( lengthDiff > 0 ) {
60- const lastItemId = stableIdsRef . current [ stableIdsRef . current . length - 1 ] ;
61- inputRefsRef . current . get ( lastItemId ) ?. focus ( ) ;
62- } else if ( lengthDiff < 0 && removedIndexRef . current !== null ) {
63- // Focus the input at the removed index (or previous if it was last)
64- const focusIndex = Math . min ( removedIndexRef . current , values . length - 1 ) ;
65- if ( focusIndex >= 0 ) {
66- inputRefsRef . current . get ( stableIdsRef . current [ focusIndex ] ) ?. focus ( ) ;
67- }
68- removedIndexRef . current = null ;
69- }
70- previousLengthRef . current = values . length ;
71- } , [ values . length ] ) ;
31+ const { values, maxLength, isAtMaxLength, isAtMinLength, lastItemIsEmpty } = useArrayValueState ( value , arg ) ;
32+ const { stableIds, removeId } = useStableIds ( values . length ) ;
33+ const { setRef, getRef, removeRefsForIds } = useInputRefs ( ) ;
34+ const { setRemovedIndex } = useAutoFocus ( values , stableIds , getRef ) ;
7235
7336 const updateValue = ( newValues : string [ ] ) => {
7437 if ( ! onChange ) return ;
@@ -78,46 +41,36 @@ const ArrayArgumentInput = forwardRef<HTMLInputElement, ArrayArgumentInputProps>
7841 } ;
7942
8043 const handleItemChange = ( index : number , newValue : string ) => {
81- const sanitizedValue = newValue . replace ( / , / g, '' ) ;
82-
44+ const sanitizeArrayItem = ( value : string ) => value . replace ( / , / g, '' ) ;
45+ const sanitizedValue = sanitizeArrayItem ( newValue ) ;
8346 const newValues = [ ...values ] ;
8447 newValues [ index ] = sanitizedValue ;
8548 updateValue ( newValues ) ;
8649 } ;
8750
8851 const handlePaste = ( index : number , e : React . ClipboardEvent < HTMLInputElement > ) => {
8952 e . preventDefault ( ) ;
90- const pastedValues = e . clipboardData
91- . getData ( 'text' )
92- . split ( ',' )
93- . map ( v => v . trim ( ) )
94- . filter ( v => v !== '' ) ;
53+
54+ const pastedText = e . clipboardData . getData ( 'text' ) ;
55+ const pastedValues = parseCommaSeparatedValues ( pastedText ) . filter ( v => v !== '' ) ;
9556
9657 if ( pastedValues . length === 0 ) return ;
9758
9859 const newValues = [ ...values ] ;
9960 newValues [ index ] = pastedValues [ 0 ] ;
10061
101- if ( pastedValues . length > 1 ) {
102- const remainingValues = pastedValues . slice ( 1 ) ;
103- const maxLength = getArrayMaxLength ( arg ) ?? MAX_ARRAY_INPUTS ;
104- const availableSlots = maxLength - newValues . length ;
105- const valuesToAdd = remainingValues . slice ( 0 , availableSlots ) ;
62+ const remainingValues = pastedValues . slice ( 1 ) ;
63+ const availableSlots = Math . max ( 0 , maxLength - newValues . length ) ;
64+ const valuesToAdd = remainingValues . slice ( 0 , availableSlots ) ;
10665
107- if ( valuesToAdd . length > 0 ) {
108- newValues . push ( ...valuesToAdd ) ;
109- const newIds = Array . from ( { length : valuesToAdd . length } , ( ) => `item-${ idCounterRef . current ++ } ` ) ;
110- stableIdsRef . current = [ ...stableIdsRef . current , ...newIds ] ;
111- }
66+ if ( valuesToAdd . length > 0 ) {
67+ newValues . push ( ...valuesToAdd ) ;
68+ // IDs will be generated automatically by useStableIds when values.length changes
11269 }
113-
70+ if ( ! newValues ) return ;
11471 updateValue ( newValues ) ;
11572 } ;
11673
117- const lastItemIsEmpty = values . length > 0 && values [ values . length - 1 ] === '' ;
118- const maxLength = getArrayMaxLength ( arg ) ?? MAX_ARRAY_INPUTS ;
119- const isAtMaxLength = values . length >= maxLength ;
120-
12174 const handleAddItem = ( ) => {
12275 if ( lastItemIsEmpty || isAtMaxLength ) return ;
12376 updateValue ( [ ...values , '' ] ) ;
@@ -126,10 +79,10 @@ const ArrayArgumentInput = forwardRef<HTMLInputElement, ArrayArgumentInputProps>
12679 const handleRemoveItem = ( index : number ) => {
12780 if ( values . length <= 1 ) return ;
12881
129- removedIndexRef . current = index ;
130- const newIds = [ ... stableIdsRef . current ] ;
131- newIds . splice ( index , 1 ) ;
132- stableIdsRef . current = newIds ;
82+ setRemovedIndex ( index ) ;
83+ const itemId = stableIds [ index ] ;
84+ removeRefsForIds ( [ itemId ] ) ;
85+ removeId ( index ) ;
13386 updateValue ( values . filter ( ( _ , i ) => i !== index ) ) ;
13487 } ;
13588
@@ -138,8 +91,7 @@ const ArrayArgumentInput = forwardRef<HTMLInputElement, ArrayArgumentInputProps>
13891 e . preventDefault ( ) ;
13992 handleAddItem ( ) ;
14093 }
141- // Allow Delete/Backspace to remove item when input is empty and not the last item
142- if ( ( e . key === 'Delete' || e . key === 'Backspace' ) && values [ index ] === '' && values . length > 1 ) {
94+ if ( ( e . key === 'Delete' || e . key === 'Backspace' ) && values [ index ] === '' ) {
14395 e . preventDefault ( ) ;
14496 handleRemoveItem ( index ) ;
14597 }
@@ -162,55 +114,172 @@ const ArrayArgumentInput = forwardRef<HTMLInputElement, ArrayArgumentInputProps>
162114 }
163115 >
164116 < div className = "e-space-y-2" >
165- { values . map ( ( item , index ) => (
166- < div key = { stableIdsRef . current [ index ] } className = "e-flex e-items-center e-gap-2" >
167- < Input
168- ref = { inputElement => {
169- const itemId = stableIdsRef . current [ index ] ;
170- if ( inputElement ) {
171- inputRefsRef . current . set ( itemId , inputElement ) ;
172- } else {
173- inputRefsRef . current . delete ( itemId ) ;
174- }
175- if ( index === 0 ) {
117+ { values . map ( ( item , index ) => {
118+ const itemId = stableIds [ index ] ;
119+ return (
120+ < div key = { itemId } className = "e-flex e-items-center e-gap-2" >
121+ < Input
122+ ref = { inputElement => {
123+ setRef ( itemId , inputElement ) ;
124+ if ( index !== 0 ) return ;
176125 if ( typeof ref === 'function' ) {
177126 ref ( inputElement ) ;
178127 } else if ( ref ) {
179- ( ref as React . MutableRefObject < HTMLInputElement | null > ) . current =
180- inputElement ;
128+ ref . current = inputElement ;
181129 }
182- }
183- } }
184- id = { index === 0 ? inputId : undefined }
185- variant = "dark"
186- value = { item }
187- onChange = { e => handleItemChange ( index , e . target . value ) }
188- onPaste = { e => handlePaste ( index , e ) }
189- onKeyDown = { e => handleKeyDown ( index , e ) }
190- onBlur = { onBlur }
191- aria-invalid = { Boolean ( error ) }
192- { ... props }
193- />
194- { values . length > 1 && (
195- < button
196- type = "button"
197- onClick = { ( ) => handleRemoveItem ( index ) }
198- className = "e-m-0 e-flex e-h-6 e-w-6 e-cursor-pointer e-items-center e-justify-center e-border-none e-bg-transparent e-p-0 e-text-xs e-text-neutral-400 hover:e-text-destructive "
199- aria-label = "Remove item"
200- tabIndex = { - 1 }
201- >
202- < X size = { 14 } / >
203- </ button >
204- ) }
205- </ div >
206- ) ) }
130+ } }
131+ id = { index === 0 ? inputId : undefined }
132+ variant = "dark"
133+ value = { item }
134+ onChange = { e => handleItemChange ( index , e . target . value ) }
135+ onPaste = { e => handlePaste ( index , e ) }
136+ onKeyDown = { e => handleKeyDown ( index , e ) }
137+ onBlur = { onBlur }
138+ aria-invalid = { Boolean ( error ) }
139+ { ... props }
140+ />
141+ { ! isAtMinLength && (
142+ < button
143+ type = " button"
144+ onClick = { ( ) => handleRemoveItem ( index ) }
145+ className = "e-m-0 e-flex e-h-6 e-w-6 e-cursor-pointer e-items-center e-justify-center e-border-none e-bg-transparent e-p-0 e-text-xs e-text-neutral-400 hover:e-text-destructive"
146+ aria-label = "Remove item "
147+ tabIndex = { - 1 }
148+ >
149+ < X size = { 14 } / >
150+ </ button >
151+ ) }
152+ </ div >
153+ ) ;
154+ } ) }
207155 </ div >
208156 </ ArgumentInputLayout >
209157 ) ;
210158 }
211159) ;
212160ArrayArgumentInput . displayName = 'ArrayArgumentInput' ;
213161
162+ /**
163+ * Manages stable IDs for array items to prevent React key issues when items are removed.
164+ * Generates new IDs when the array grows, and provides a method to remove IDs when items are deleted.
165+ */
166+ function useStableIds ( arrayLength : number ) {
167+ const idCounterRef = useRef ( 0 ) ;
168+ const stableIdsRef = useRef < string [ ] > ( [ ] ) ;
169+
170+ // Add new IDs when array grows
171+ if ( stableIdsRef . current . length < arrayLength ) {
172+ const newIds = Array . from (
173+ { length : arrayLength - stableIdsRef . current . length } ,
174+ ( ) => `item-${ idCounterRef . current ++ } `
175+ ) ;
176+ stableIdsRef . current = [ ...stableIdsRef . current , ...newIds ] ;
177+ }
178+
179+ const removeId = ( index : number ) => {
180+ const newIds = [ ...stableIdsRef . current ] ;
181+ newIds . splice ( index , 1 ) ;
182+ stableIdsRef . current = newIds ;
183+ } ;
184+
185+ return { removeId, stableIds : stableIdsRef . current } ;
186+ }
187+
188+ /**
189+ * Manages a map of input element refs keyed by stable item IDs.
190+ * Provides methods to set and get refs, and to remove refs when items are deleted.
191+ */
192+ function useInputRefs ( ) {
193+ const inputRefsRef = useRef < Map < string , HTMLInputElement > > ( new Map ( ) ) ;
194+
195+ const setRef = ( itemId : string , element : HTMLInputElement | null ) => {
196+ if ( element ) {
197+ inputRefsRef . current . set ( itemId , element ) ;
198+ } else {
199+ inputRefsRef . current . delete ( itemId ) ;
200+ }
201+ } ;
202+
203+ const getRef = ( itemId : string ) : HTMLInputElement | undefined => inputRefsRef . current . get ( itemId ) ;
204+
205+ const removeRefsForIds = ( idsToRemove : string [ ] ) => {
206+ idsToRemove . forEach ( id => inputRefsRef . current . delete ( id ) ) ;
207+ } ;
208+
209+ return { getRef, removeRefsForIds, setRef } ;
210+ }
211+
212+ /**
213+ * Manages auto-focus behavior when array items are added or removed.
214+ */
215+ function useAutoFocus (
216+ values : string [ ] ,
217+ stableIds : string [ ] ,
218+ getInputRef : ( itemId : string ) => HTMLInputElement | undefined
219+ ) {
220+ const previousLengthRef = useRef ( values . length ) ;
221+ const removedIndexRef = useRef < number | null > ( null ) ;
222+
223+ useEffect ( ( ) => {
224+ const lengthDiff = values . length - previousLengthRef . current ;
225+
226+ if ( lengthDiff > 0 ) {
227+ // Item was added - focus the last item
228+ const lastItemId = stableIds [ stableIds . length - 1 ] ;
229+ getInputRef ( lastItemId ) ?. focus ( ) ;
230+ previousLengthRef . current = values . length ;
231+ return ;
232+ }
233+
234+ if ( lengthDiff < 0 && removedIndexRef . current !== null ) {
235+ // Item was removed - focus the item at the removed index (or previous if it was last)
236+ const focusIndex = Math . max ( 0 , Math . min ( removedIndexRef . current , values . length - 1 ) ) ;
237+ getInputRef ( stableIds [ focusIndex ] ) ?. focus ( ) ;
238+ removedIndexRef . current = null ;
239+ }
240+
241+ previousLengthRef . current = values . length ;
242+ } , [ values . length , stableIds , getInputRef ] ) ;
243+
244+ const setRemovedIndex = ( index : number ) => {
245+ removedIndexRef . current = index ;
246+ } ;
247+
248+ return { setRemovedIndex } ;
249+ }
250+
251+ /**
252+ * Manages array value derived state and provides helpers for UI logic.
253+ */
254+ function useArrayValueState ( value : string | number | readonly string [ ] | undefined , arg : ArgField ) {
255+ const maxArrayInputs = 100 ;
256+
257+ const parseStateValue = ( value : string | number | readonly string [ ] | undefined ) : string [ ] => {
258+ if ( ! value || typeof value !== 'string' ) return [ '' ] ;
259+ const trimmed = value . trim ( ) ;
260+ if ( trimmed === '' ) return [ '' ] ;
261+ return parseCommaSeparatedValues ( trimmed ) ;
262+ } ;
263+
264+ const values = parseStateValue ( value ) ;
265+ const maxLength = getArrayMaxLength ( arg ) ?? maxArrayInputs ;
266+ const isAtMinLength = values . length <= 1 ;
267+ const isAtMaxLength = values . length >= maxLength ;
268+ const lastItemIsEmpty = values . length > 0 && values [ values . length - 1 ] === '' ;
269+
270+ return {
271+ isAtMaxLength,
272+ isAtMinLength,
273+ lastItemIsEmpty,
274+ maxLength,
275+ values,
276+ } ;
277+ }
278+
279+ function parseCommaSeparatedValues ( value : string ) : string [ ] {
280+ return value . split ( ',' ) . map ( v => v . trim ( ) ) ;
281+ }
282+
214283interface SingleArgumentInputProps extends ArgumentInputProps {
215284 inputId : string ;
216285}
0 commit comments