@pipelex/mthds-form 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +7 -2
  2. package/dist/chunk-6XD6UZSU.js +28 -0
  3. package/dist/chunk-6XD6UZSU.js.map +1 -0
  4. package/dist/chunk-7BMOPXDY.js +205 -0
  5. package/dist/chunk-7BMOPXDY.js.map +1 -0
  6. package/dist/chunk-GKV77RAS.js +46 -0
  7. package/dist/chunk-GKV77RAS.js.map +1 -0
  8. package/dist/chunk-HARYEWNG.js +31 -0
  9. package/dist/chunk-HARYEWNG.js.map +1 -0
  10. package/dist/chunk-KFZ5MZOM.js +44 -0
  11. package/dist/chunk-KFZ5MZOM.js.map +1 -0
  12. package/dist/chunk-NMT7BRLO.js +40 -0
  13. package/dist/chunk-NMT7BRLO.js.map +1 -0
  14. package/dist/chunk-O4O3QNVC.js +146 -0
  15. package/dist/chunk-O4O3QNVC.js.map +1 -0
  16. package/dist/chunk-PM55AHRO.js +52 -0
  17. package/dist/chunk-PM55AHRO.js.map +1 -0
  18. package/dist/chunk-RPA7DNSK.js +41 -0
  19. package/dist/chunk-RPA7DNSK.js.map +1 -0
  20. package/dist/chunk-V3OV2XQT.js +12 -0
  21. package/dist/chunk-V3OV2XQT.js.map +1 -0
  22. package/dist/chunk-VMLXO5JS.js +132 -0
  23. package/dist/chunk-VMLXO5JS.js.map +1 -0
  24. package/dist/chunk-VTPUDBSJ.js +61 -0
  25. package/dist/chunk-VTPUDBSJ.js.map +1 -0
  26. package/dist/chunk-WXBSM2CE.js +201 -0
  27. package/dist/chunk-WXBSM2CE.js.map +1 -0
  28. package/dist/chunk-XEPD5VJQ.js +99 -0
  29. package/dist/chunk-XEPD5VJQ.js.map +1 -0
  30. package/dist/core/contracts.d.ts +143 -0
  31. package/dist/core/contracts.js +4 -0
  32. package/dist/core/contracts.js.map +1 -0
  33. package/dist/core/date-format.d.ts +87 -0
  34. package/dist/core/date-format.js +3 -0
  35. package/dist/core/date-format.js.map +1 -0
  36. package/dist/core/derive.d.ts +20 -0
  37. package/dist/core/derive.js +7 -0
  38. package/dist/core/derive.js.map +1 -0
  39. package/dist/{descriptor-X6MwFIWu.d.ts → core/descriptor.d.ts} +42 -1
  40. package/dist/core/descriptor.js +3 -0
  41. package/dist/core/descriptor.js.map +1 -0
  42. package/dist/core/gate-validator.d.ts +27 -0
  43. package/dist/core/gate-validator.js +4 -0
  44. package/dist/core/gate-validator.js.map +1 -0
  45. package/dist/core/gate.d.ts +144 -0
  46. package/dist/core/gate.js +13 -0
  47. package/dist/core/gate.js.map +1 -0
  48. package/dist/core/index.d.ts +12 -615
  49. package/dist/core/index.js +14 -1
  50. package/dist/core/native-concepts.d.ts +78 -0
  51. package/dist/core/native-concepts.js +3 -0
  52. package/dist/core/native-concepts.js.map +1 -0
  53. package/dist/core/normalize-schema.d.ts +30 -0
  54. package/dist/core/normalize-schema.js +4 -0
  55. package/dist/core/normalize-schema.js.map +1 -0
  56. package/dist/core/own-property.d.ts +46 -0
  57. package/dist/core/own-property.js +3 -0
  58. package/dist/core/own-property.js.map +1 -0
  59. package/dist/core/readiness.d.ts +89 -0
  60. package/dist/core/readiness.js +4 -0
  61. package/dist/core/readiness.js.map +1 -0
  62. package/dist/core/schema-utils.d.ts +74 -0
  63. package/dist/core/schema-utils.js +3 -0
  64. package/dist/core/schema-utils.js.map +1 -0
  65. package/dist/core/validation-message.d.ts +39 -0
  66. package/dist/core/validation-message.js +4 -0
  67. package/dist/core/validation-message.js.map +1 -0
  68. package/dist/core/values.d.ts +91 -0
  69. package/dist/core/values.js +11 -0
  70. package/dist/core/values.js.map +1 -0
  71. package/dist/core/wire-format.d.ts +95 -0
  72. package/dist/core/wire-format.js +7 -0
  73. package/dist/core/wire-format.js.map +1 -0
  74. package/dist/react/index.d.ts +22 -1
  75. package/dist/react/index.js +128 -48
  76. package/dist/react/index.js.map +1 -1
  77. package/dist/styles.css +1 -1
  78. package/package.json +5 -1
  79. package/dist/chunk-YWMUM7BC.js +0 -966
  80. package/dist/chunk-YWMUM7BC.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import { R as RunField, B as BooleanRunField, D as DateRunField, E as EnumRunField, F as FileRunField, L as ListRunField, N as NumberRunField, O as ObjectRunField, P as ProseRunField, T as TextRunField, U as UnknownRunField, C as ConceptCategory } from '../descriptor-X6MwFIWu.js';
3
+ import { RunField, BooleanRunField, DateRunField, EnumRunField, FileRunField, ListRunField, NumberRunField, ObjectRunField, ProseRunField, TextRunField, UnknownRunField, ConceptCategory } from '../core/descriptor.js';
4
4
 
5
5
  /**
6
6
  * Ambient state that any field in the tree may need, threaded unchanged through
@@ -114,6 +114,25 @@ interface ListFieldProps {
114
114
  * own remove control; "Add" appends a fresh entry. The element renders through
115
115
  * the same `FieldRenderer`, so a list of documents gets dropzones and a list of
116
116
  * structured concepts gets nested cards - no special-casing per element type.
117
+ *
118
+ * **A row is a thing, not a slot.** Its React key is generated when the row
119
+ * appears and travels with it, so removing an earlier row moves the later ones
120
+ * instead of renumbering them into each other - which is what used to drop a
121
+ * half-typed value or a pending preview onto the neighbouring row. The row's
122
+ * field ID stays POSITIONAL (`cvs.1`), because that ID is a PATH: a host writes
123
+ * an upload back with `setValueAtPath(values, id.split('.'), value)`, and
124
+ * `setValueAtPath` is a pure function of the value tree. Handing out an opaque
125
+ * token instead would mean the kernel keeping a live token-to-index registry for
126
+ * that function to consult, which is exactly the kind of hidden state the
127
+ * headless core does not have.
128
+ *
129
+ * The positional ID is why removal is BLOCKED while a file is arriving anywhere
130
+ * in this list - see `listIsBusy`. Together those two are the whole fix: a
131
+ * generated key cannot rescue a write-back either, because the keys live in this
132
+ * control's state and not in the value, so a host that replaces `values`
133
+ * wholesale reconciles by length just as positions do. What a stable identity
134
+ * buys over a position is exactly the reorderings the KERNEL performs, and it
135
+ * performs one.
117
136
  */
118
137
  declare function ListField({ field, value, onChange, id, error, env }: ListFieldProps): react.JSX.Element;
119
138
 
@@ -249,6 +268,8 @@ interface FieldStrings {
249
268
  addItem: string;
250
269
  removeItemAria: (index: number) => string;
251
270
  itemsCount: (count: number) => string;
271
+ /** The same badge for a list the method gave an exact count (`Concept[N]`). */
272
+ itemsCountOf: (count: number, total: number) => string;
252
273
  uploading: string;
253
274
  dropToUpload: string;
254
275
  dropOrBrowse: string;
@@ -1,5 +1,7 @@
1
1
  "use client";
2
- import { toStoredDateValue, toDateInputValue, isFilled } from '../chunk-YWMUM7BC.js';
2
+ import { isFilled } from '../chunk-PM55AHRO.js';
3
+ import { ownProp } from '../chunk-V3OV2XQT.js';
4
+ import { toStoredDateValue, toDateInputValue } from '../chunk-GKV77RAS.js';
3
5
  import { clsx } from 'clsx';
4
6
  import { twMerge } from 'tailwind-merge';
5
7
  import { ChevronDown, ChevronUp, Check, List, Braces, ListChecks, ToggleLeft, Hash, Image, FileText, CalendarDays, Type, Loader2, Upload, Link2, ImageOff, EyeOff, Eye, X, Minus, Plus, Trash2 } from 'lucide-react';
@@ -137,6 +139,7 @@ var DEFAULT_FIELD_STRINGS = {
137
139
  addItem: "Add item",
138
140
  removeItemAria: (index) => `Remove item ${index}`,
139
141
  itemsCount: (count) => count === 1 ? "1 item" : `${count} items`,
142
+ itemsCountOf: (count, total) => `${count} of ${total} ${total === 1 ? "item" : "items"}`,
140
143
  uploading: "Uploading\u2026",
141
144
  dropToUpload: "Drop to upload",
142
145
  dropOrBrowse: "Drop a file or click to browse",
@@ -239,6 +242,13 @@ function DateField({ field, value, onChange, id, error, disabled }) {
239
242
  }
240
243
  var IMAGE_EXT_RE = /\.(jpg|jpeg|png|gif|webp|bmp|svg|avif)(\?|$)/i;
241
244
  var PDF_EXT_RE = /\.pdf(\?|$)/i;
245
+ var DATA_URL_MIME_RE = /^data:([^;,]+)/i;
246
+ function isDirectlyViewable(url) {
247
+ return !!url && (/^https?:/i.test(url) || /^data:/i.test(url) || /^blob:/i.test(url));
248
+ }
249
+ function dataUrlMime(url) {
250
+ return DATA_URL_MIME_RE.exec(url)?.[1]?.toLowerCase();
251
+ }
242
252
  function DocumentField(props) {
243
253
  return /* @__PURE__ */ jsx(FileField, { ...props, category: "document" });
244
254
  }
@@ -261,24 +271,31 @@ function FileField({
261
271
  const [showUrl, setShowUrl] = useState(false);
262
272
  const [previewOpen, setPreviewOpen] = useState(false);
263
273
  const [localPreview, setLocalPreview] = useState(null);
274
+ const [resolved, setResolved] = useState(null);
264
275
  const localUrlRef = useRef(null);
265
276
  const setLocal = useCallback((next) => {
266
277
  if (localUrlRef.current) URL.revokeObjectURL(localUrlRef.current);
267
- localUrlRef.current = next?.url ?? null;
278
+ localUrlRef.current = next?.objectUrl ?? null;
268
279
  setLocalPreview(next);
269
280
  }, []);
270
281
  useEffect(() => () => setLocal(null), [setLocal]);
271
282
  const handleFile = useCallback(
272
283
  (file) => {
273
- setLocal({ url: URL.createObjectURL(file), type: file.type });
284
+ setLocal({ objectUrl: URL.createObjectURL(file), type: file.type });
274
285
  onDropFile(file);
275
286
  },
276
287
  [setLocal, onDropFile]
277
288
  );
289
+ const busy = disabled || uploading;
278
290
  const { getRootProps, getInputProps, isDragActive } = useDropzone({
279
291
  maxFiles: 1,
280
292
  multiple: false,
281
- disabled: disabled || uploading,
293
+ disabled: busy,
294
+ // The tab stop belongs on the INPUT, not on this div - see the root element
295
+ // below. Without this, react-dropzone puts `tabIndex: 0` and its own key
296
+ // handlers on a `role="presentation"` div, and the element a keyboard or
297
+ // voice-control user lands on is a generic with no role and no name.
298
+ noKeyboard: true,
282
299
  onDrop: (files) => {
283
300
  const file = files[0];
284
301
  if (file) handleFile(file);
@@ -286,28 +303,43 @@ function FileField({
286
303
  });
287
304
  const clear = useCallback(() => {
288
305
  setLocal(null);
289
- setResolvedSrc(null);
306
+ setResolved(null);
290
307
  setPreviewOpen(false);
291
308
  onChange(void 0);
292
309
  }, [setLocal, onChange]);
310
+ const localIsCurrent = !!localPreview && (localPreview.boundUrl === void 0 || localPreview.boundUrl === value?.url);
311
+ useEffect(() => {
312
+ if (!localPreview) return;
313
+ if (localPreview.boundUrl === void 0) {
314
+ if (uploading || !value?.url) return;
315
+ setLocalPreview({ ...localPreview, boundUrl: value.url });
316
+ return;
317
+ }
318
+ if (value?.url !== localPreview.boundUrl) setLocal(null);
319
+ }, [localPreview, value?.url, uploading, setLocal]);
293
320
  const hasFile = !!value?.url;
294
- const ref = `${value?.filename ?? ""} ${value?.url ?? ""}`;
295
- const isImage = category === "image" || localPreview?.type.startsWith("image/") || IMAGE_EXT_RE.test(ref);
296
- const isPdf = localPreview?.type === "application/pdf" || PDF_EXT_RE.test(ref);
321
+ const localType = localIsCurrent ? localPreview?.type : void 0;
322
+ const filename = value?.filename ?? "";
323
+ const url = value?.url ?? "";
324
+ const urlMime = dataUrlMime(url);
325
+ const isImage = category === "image" || localType?.startsWith("image/") === true || urlMime?.startsWith("image/") === true || IMAGE_EXT_RE.test(filename) || IMAGE_EXT_RE.test(url);
326
+ const isPdf = localType === "application/pdf" || urlMime === "application/pdf" || PDF_EXT_RE.test(filename) || PDF_EXT_RE.test(url);
297
327
  const canPreview = isImage || isPdf;
298
- const [resolvedSrc, setResolvedSrc] = useState(null);
299
- const storageUri = value?.url && !value.url.startsWith("http") ? value.url : null;
328
+ const storageUri = value?.url && !isDirectlyViewable(value.url) ? value.url : null;
300
329
  useEffect(() => {
301
- if (!previewOpen || !storageUri || localPreview || !resolveUrl) return;
330
+ if (!previewOpen || !storageUri || localIsCurrent || !resolveUrl) return;
302
331
  let cancelled = false;
303
- void resolveUrl(storageUri).then((url) => {
304
- if (!cancelled) setResolvedSrc(url);
332
+ void resolveUrl(storageUri).then((src) => {
333
+ if (!cancelled) setResolved(src ? { uri: storageUri, src } : null);
334
+ }).catch(() => {
335
+ if (!cancelled) setResolved(null);
305
336
  });
306
337
  return () => {
307
338
  cancelled = true;
308
339
  };
309
- }, [previewOpen, storageUri, localPreview, resolveUrl]);
310
- const previewSrc = localPreview?.url ?? (value?.url?.startsWith("http") ? value.url : resolvedSrc ?? void 0);
340
+ }, [previewOpen, storageUri, localIsCurrent, resolveUrl]);
341
+ const resolvedSrc = resolved && resolved.uri === storageUri ? resolved.src : void 0;
342
+ const previewSrc = (localIsCurrent ? localPreview?.objectUrl : void 0) ?? (isDirectlyViewable(value?.url) ? value.url : resolvedSrc);
311
343
  return /* @__PURE__ */ jsxs(
312
344
  FieldShell,
313
345
  {
@@ -318,6 +350,7 @@ function FileField({
318
350
  description: field.description,
319
351
  required: field.required,
320
352
  error,
353
+ htmlFor: id,
321
354
  children: [
322
355
  hasFile && !uploading ? /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
323
356
  /* @__PURE__ */ jsx(
@@ -332,36 +365,53 @@ function FileField({
332
365
  }
333
366
  ),
334
367
  previewOpen && (previewSrc && isImage ? /* @__PURE__ */ jsx(ImagePreview, { src: previewSrc, filename: value?.filename }) : previewSrc && isPdf ? /* @__PURE__ */ jsx(PdfPreview, { src: previewSrc }) : /* @__PURE__ */ jsx("div", { className: "flex h-24 items-center justify-center rounded-md border border-border bg-muted", children: /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin text-muted-foreground" }) }))
335
- ] }) : /* @__PURE__ */ jsxs(
336
- "div",
337
- {
338
- ...getRootProps(),
339
- className: cn(
340
- "flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center",
341
- "cursor-pointer select-none transition-colors",
342
- isDragActive ? "border-primary/60 bg-primary/5" : "border-border bg-input hover:border-border hover:bg-muted",
343
- (disabled || uploading) && "cursor-not-allowed opacity-60"
344
- ),
345
- children: [
346
- /* @__PURE__ */ jsx("input", { ...getInputProps(), id, "aria-invalid": !!error }),
347
- uploading ? /* @__PURE__ */ jsxs(Fragment, { children: [
348
- /* @__PURE__ */ jsx(Loader2, { className: "h-5 w-5 animate-spin text-muted-foreground" }),
349
- /* @__PURE__ */ jsx("p", { className: "text-[12px] text-muted-foreground", children: s.uploading })
350
- ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
351
- /* @__PURE__ */ jsx(Upload, { className: "h-5 w-5 text-muted-foreground", strokeWidth: 1.75 }),
352
- /* @__PURE__ */ jsxs("div", { className: "space-y-0.5", children: [
353
- /* @__PURE__ */ jsx("p", { className: "text-[13px] text-foreground", children: isDragActive ? s.dropToUpload : s.dropOrBrowse }),
354
- field.accept && /* @__PURE__ */ jsx("p", { className: "font-mono text-[10.5px] text-muted-foreground", children: field.accept })
368
+ ] }) : (
369
+ // `role="presentation"` and no tab stop: this div is the drop TARGET and
370
+ // the visual affordance, and the control inside it is a real
371
+ // `<input type="file">`, which already has the right role and now has a
372
+ // name. Giving the div a `role="button"` of its own would put a second
373
+ // named control in the accessibility tree for one value. The focus ring
374
+ // is `focus-within` because the element that takes focus is clip-hidden.
375
+ /* @__PURE__ */ jsxs(
376
+ "div",
377
+ {
378
+ ...getRootProps(),
379
+ className: cn(
380
+ "flex flex-col items-center justify-center gap-2 rounded-lg border border-dashed px-4 py-6 text-center",
381
+ "cursor-pointer select-none transition-colors",
382
+ "focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
383
+ isDragActive ? "border-primary/60 bg-primary/5" : "border-border bg-input hover:border-border hover:bg-muted",
384
+ busy && "cursor-not-allowed opacity-60"
385
+ ),
386
+ children: [
387
+ /* @__PURE__ */ jsx(
388
+ "input",
389
+ {
390
+ ...getInputProps({ tabIndex: 0 }),
391
+ id,
392
+ disabled: busy,
393
+ "aria-invalid": !!error
394
+ }
395
+ ),
396
+ uploading ? /* @__PURE__ */ jsxs(Fragment, { children: [
397
+ /* @__PURE__ */ jsx(Loader2, { className: "h-5 w-5 animate-spin text-muted-foreground" }),
398
+ /* @__PURE__ */ jsx("p", { className: "text-[12px] text-muted-foreground", children: s.uploading })
399
+ ] }) : /* @__PURE__ */ jsxs(Fragment, { children: [
400
+ /* @__PURE__ */ jsx(Upload, { className: "h-5 w-5 text-muted-foreground", strokeWidth: 1.75 }),
401
+ /* @__PURE__ */ jsxs("div", { className: "space-y-0.5", children: [
402
+ /* @__PURE__ */ jsx("p", { className: "text-[13px] text-foreground", children: isDragActive ? s.dropToUpload : s.dropOrBrowse }),
403
+ field.accept && /* @__PURE__ */ jsx("p", { className: "font-mono text-[10.5px] text-muted-foreground", children: field.accept })
404
+ ] })
355
405
  ] })
356
- ] })
357
- ]
358
- }
406
+ ]
407
+ }
408
+ )
359
409
  ),
360
410
  !showUrl ? /* @__PURE__ */ jsxs(
361
411
  "button",
362
412
  {
363
413
  type: "button",
364
- disabled,
414
+ disabled: busy,
365
415
  onClick: () => setShowUrl(true),
366
416
  className: "inline-flex items-center gap-1 font-mono text-[11px] text-muted-foreground transition-colors hover:text-foreground disabled:opacity-50",
367
417
  children: [
@@ -375,7 +425,7 @@ function FileField({
375
425
  type: "text",
376
426
  autoFocus: true,
377
427
  value: value?.url ?? "",
378
- disabled,
428
+ disabled: busy,
379
429
  placeholder: s.urlPlaceholder,
380
430
  onChange: (e) => {
381
431
  setLocal(null);
@@ -842,8 +892,8 @@ function ObjectField({ field, value, onChange, id, error, env }) {
842
892
  const isApp = presentation === "app";
843
893
  const data = value ?? {};
844
894
  const setChild = (name, childValue) => onChange({ ...data, [name]: childValue });
845
- const optionalEmpty = field.fields.filter((f) => !f.required && !isFilled(data[f.name]));
846
- const visible = showOptional ? field.fields : field.fields.filter((f) => f.required || isFilled(data[f.name]));
895
+ const optionalEmpty = field.fields.filter((f) => !f.required && !isFilled(ownProp(data, f.name)));
896
+ const visible = showOptional ? field.fields : field.fields.filter((f) => f.required || isFilled(ownProp(data, f.name)));
847
897
  return /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
848
898
  /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-baseline gap-x-2 gap-y-1", children: [
849
899
  /* @__PURE__ */ jsx(
@@ -872,7 +922,7 @@ function ObjectField({ field, value, onChange, id, error, env }) {
872
922
  FieldRenderer,
873
923
  {
874
924
  field: child,
875
- value: data[child.name],
925
+ value: ownProp(data, child.name),
876
926
  onChange: (v) => setChild(child.name, v),
877
927
  id: `${id}.${child.name}`,
878
928
  env
@@ -899,13 +949,19 @@ function ListField({ field, value, onChange, id, error, env }) {
899
949
  const presentation = useFieldPresentation();
900
950
  const isApp = presentation === "app";
901
951
  const items = value ?? [];
952
+ const [rowKeys, setRowKeys] = useRowKeys(items.length);
902
953
  const setItem = (index, itemValue) => {
903
954
  const next = items.slice();
904
955
  next[index] = itemValue;
905
956
  onChange(next);
906
957
  };
907
- const removeItem = (index) => onChange(items.filter((_, i) => i !== index));
958
+ const removeItem = (index) => {
959
+ setRowKeys((prev) => prev.filter((_, i) => i !== index));
960
+ onChange(items.filter((_, i) => i !== index));
961
+ };
908
962
  const addItem = () => onChange([...items, emptyValue(field.item)]);
963
+ const busy = listIsBusy(id, env?.uploadingIds);
964
+ const isFull = field.maxItemCount !== void 0 && items.length >= field.maxItemCount;
909
965
  return /* @__PURE__ */ jsxs("div", { className: "space-y-2", children: [
910
966
  /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-baseline gap-x-2 gap-y-1", children: [
911
967
  /* @__PURE__ */ jsx(
@@ -919,7 +975,7 @@ function ListField({ field, value, onChange, id, error, env }) {
919
975
  }
920
976
  ),
921
977
  !isApp && /* @__PURE__ */ jsx(ConceptPill, { conceptRef: field.conceptRef, category: "list" }),
922
- /* @__PURE__ */ jsx("span", { className: "font-mono text-[10.5px] text-muted-foreground", children: s.itemsCount(items.length) }),
978
+ /* @__PURE__ */ jsx("span", { className: "font-mono text-[10.5px] text-muted-foreground", children: field.itemCount !== void 0 && field.maxItemCount === field.itemCount ? s.itemsCountOf(items.length, field.itemCount) : s.itemsCount(items.length) }),
923
979
  !field.required && /* @__PURE__ */ jsx("span", { className: "ml-auto font-mono text-[10px] uppercase tracking-wider text-muted-foreground/70", children: s.optionalBadge })
924
980
  ] }),
925
981
  field.description && /* @__PURE__ */ jsx("p", { className: "text-[12px] leading-relaxed text-muted-foreground", children: field.description }),
@@ -946,7 +1002,7 @@ function ListField({ field, value, onChange, id, error, env }) {
946
1002
  {
947
1003
  type: "button",
948
1004
  onClick: () => removeItem(index),
949
- disabled: env?.disabled,
1005
+ disabled: env?.disabled || busy,
950
1006
  "aria-label": s.removeItemAria(index + 1),
951
1007
  className: "mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-destructive disabled:opacity-50",
952
1008
  children: /* @__PURE__ */ jsx(Trash2, { className: "h-3.5 w-3.5" })
@@ -954,14 +1010,14 @@ function ListField({ field, value, onChange, id, error, env }) {
954
1010
  )
955
1011
  ]
956
1012
  },
957
- index
1013
+ rowKeys[index] ?? index
958
1014
  )),
959
1015
  /* @__PURE__ */ jsxs(
960
1016
  "button",
961
1017
  {
962
1018
  type: "button",
963
1019
  onClick: addItem,
964
- disabled: env?.disabled,
1020
+ disabled: env?.disabled || isFull,
965
1021
  className: cn(
966
1022
  "inline-flex h-9 w-full items-center justify-center gap-1.5 rounded-md border border-dashed border-border text-[13px] text-muted-foreground transition-colors",
967
1023
  "hover:border-primary/40 hover:bg-muted hover:text-foreground disabled:cursor-not-allowed disabled:opacity-50"
@@ -976,6 +1032,30 @@ function ListField({ field, value, onChange, id, error, env }) {
976
1032
  error && /* @__PURE__ */ jsx("p", { className: "text-[11px] text-destructive", role: "alert", children: error })
977
1033
  ] });
978
1034
  }
1035
+ function listIsBusy(id, uploadingIds) {
1036
+ if (!uploadingIds?.size) return false;
1037
+ const prefix = `${id}.`;
1038
+ for (const uploading of uploadingIds) if (uploading.startsWith(prefix)) return true;
1039
+ return false;
1040
+ }
1041
+ function useRowKeys(length) {
1042
+ const [state, setState] = useState(() => ({
1043
+ keys: Array.from({ length }, (_, i) => i),
1044
+ next: length
1045
+ }));
1046
+ if (state.keys.length !== length) {
1047
+ setState((prev) => {
1048
+ if (length <= prev.keys.length) return { keys: prev.keys.slice(0, length), next: prev.next };
1049
+ const added = length - prev.keys.length;
1050
+ return {
1051
+ keys: [...prev.keys, ...Array.from({ length: added }, (_, i) => prev.next + i)],
1052
+ next: prev.next + added
1053
+ };
1054
+ });
1055
+ }
1056
+ const update = (fn) => setState((prev) => ({ keys: fn(prev.keys), next: prev.next }));
1057
+ return [state.keys, update];
1058
+ }
979
1059
  function emptyValue(field) {
980
1060
  switch (field.kind) {
981
1061
  case "boolean":