Skip to content

Commit 7bfb766

Browse files
askovrogaldh
authored andcommitted
refactor: simplify ArrayArgumentInput component
1 parent 7a8f6ee commit 7bfb766

File tree

1 file changed

+176
-107
lines changed

1 file changed

+176
-107
lines changed

app/features/idl/interactive-idl/ui/ArgumentInput.tsx

Lines changed: 176 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,6 @@ import { X } from 'react-feather';
77

88
import { getArrayMaxLength, isArrayArg, isRequiredArg, isVectorArg } from '../lib/instruction-args';
99

10-
const MAX_ARRAY_INPUTS = 100;
11-
1210
export 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
}
3129
const 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
);
212160
ArrayArgumentInput.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+
214283
interface SingleArgumentInputProps extends ArgumentInputProps {
215284
inputId: string;
216285
}

0 commit comments

Comments
 (0)