@pilotiq/pilotiq 0.12.0 → 0.13.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 (50) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/CHANGELOG.md +13 -0
  3. package/dist/react/FormCollabBindingRegistry.d.ts +17 -98
  4. package/dist/react/FormCollabBindingRegistry.d.ts.map +1 -1
  5. package/dist/react/FormCollabBindingRegistry.js.map +1 -1
  6. package/dist/react/FormStateContext.d.ts +1 -35
  7. package/dist/react/FormStateContext.d.ts.map +1 -1
  8. package/dist/react/FormStateContext.js +7 -91
  9. package/dist/react/FormStateContext.js.map +1 -1
  10. package/dist/react/RowCoordsContext.d.ts +19 -0
  11. package/dist/react/RowCoordsContext.d.ts.map +1 -0
  12. package/dist/react/RowCoordsContext.js +6 -0
  13. package/dist/react/RowCoordsContext.js.map +1 -0
  14. package/dist/react/fields/BuilderInput.d.ts.map +1 -1
  15. package/dist/react/fields/BuilderInput.js +14 -9
  16. package/dist/react/fields/BuilderInput.js.map +1 -1
  17. package/dist/react/fields/MarkdownInput.d.ts.map +1 -1
  18. package/dist/react/fields/MarkdownInput.js +35 -125
  19. package/dist/react/fields/MarkdownInput.js.map +1 -1
  20. package/dist/react/fields/RepeaterInput.d.ts.map +1 -1
  21. package/dist/react/fields/RepeaterInput.js +26 -17
  22. package/dist/react/fields/RepeaterInput.js.map +1 -1
  23. package/dist/react/fields/TextLikeInput.d.ts +11 -9
  24. package/dist/react/fields/TextLikeInput.d.ts.map +1 -1
  25. package/dist/react/fields/TextLikeInput.js +59 -189
  26. package/dist/react/fields/TextLikeInput.js.map +1 -1
  27. package/dist/react/formStateHelpers.d.ts +0 -15
  28. package/dist/react/formStateHelpers.d.ts.map +1 -1
  29. package/dist/react/formStateHelpers.js +0 -91
  30. package/dist/react/formStateHelpers.js.map +1 -1
  31. package/dist/react/index.d.ts +1 -1
  32. package/dist/react/index.d.ts.map +1 -1
  33. package/dist/react/index.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/react/FormCollabBindingRegistry.ts +17 -91
  36. package/src/react/FormStateContext.tsx +6 -125
  37. package/src/react/RowCoordsContext.tsx +23 -0
  38. package/src/react/fields/BuilderInput.tsx +22 -10
  39. package/src/react/fields/MarkdownInput.tsx +42 -129
  40. package/src/react/fields/RepeaterInput.tsx +41 -16
  41. package/src/react/fields/TextLikeInput.tsx +67 -225
  42. package/src/react/formStateHelpers.test.ts +0 -99
  43. package/src/react/formStateHelpers.ts +0 -83
  44. package/src/react/index.ts +0 -2
  45. package/dist/react/fields/textDelta.d.ts +0 -44
  46. package/dist/react/fields/textDelta.d.ts.map +0 -1
  47. package/dist/react/fields/textDelta.js +0 -80
  48. package/dist/react/fields/textDelta.js.map +0 -1
  49. package/src/react/fields/textDelta.test.ts +0 -141
  50. package/src/react/fields/textDelta.ts +0 -86
@@ -5,6 +5,7 @@ import { Button } from '../ui/button.js'
5
5
  import { SchemaRenderer } from '../SchemaRenderer.js'
6
6
  import { FormIdContext, useFormState, useRowBinding } from '../FormStateContext.js'
7
7
  import { findFieldMeta } from '../formStateHelpers.js'
8
+ import { RowCoordsContext } from '../RowCoordsContext.js'
8
9
  import { useIconFor } from '../icon-context.js'
9
10
  import { reorderRows, ExtraActionStrip, buildGridContainer } from './RepeaterInput.js'
10
11
  import { syncRowGates } from './syncRowGates.js'
@@ -191,11 +192,9 @@ export function BuilderInput({
191
192
  // the first event — without it, the picker dropdown choice doesn't
192
193
  // propagate until the user makes their first inner-field edit.
193
194
  const rowBinding = useRowBinding(name)
194
- // Phase F.5c — mirror row identities into the form's values map so dotted
195
- // row-leaf consumers (`useFieldState('${name}.${i}.data.text').textBinding`)
196
- // can resolve the row's `__id` via `rowIdAtIndex(ctx.values, name, i)`.
197
- // Without this stamp the F.5c per-row Y.Text path stays null on Builder
198
- // and inner text fields never sync. Mirrors the same fix in RepeaterInput.
195
+ // Mirror row identities into the form's values map so dotted row-leaf
196
+ // consumers can resolve the row's `__id` via `rowIdAtIndex(ctx.values,
197
+ // name, i)`. Mirrors the same plumbing in RepeaterInput.
199
198
  const formStateForIds = useFormState()
200
199
  const ctxSetValue = formStateForIds?.setValue
201
200
  useEffect(() => {
@@ -870,6 +869,15 @@ function BuilderRow({
870
869
  () => row.children.map(c => prefixFieldNames(c, dataPrefix)),
871
870
  [row.children, dataPrefix],
872
871
  )
872
+ // Row coords for dotted-path text leaves under this row — composes
873
+ // fragment-key `${arrayName}.${rowId}.${fieldName}` (Phase 1 of
874
+ // collab-row-text-tiptap-backed.md). `parseRowFieldPath` strips the
875
+ // Builder-specific `data` segment, so the coords use the array name +
876
+ // the row's stable id without referencing the dialect.
877
+ const rowCoords = useMemo(
878
+ () => ({ arrayName: name, rowIndex: index, rowId: row.id }),
879
+ [name, index, row.id],
880
+ )
873
881
 
874
882
  const RowIcon = useIconFor(showIcons ? block?.icon : undefined)
875
883
  const blockLabel = block?.label ?? row.type ?? 'Block'
@@ -878,11 +886,13 @@ function BuilderRow({
878
886
 
879
887
  if (row.hidden) {
880
888
  return (
881
- <div style={{ display: 'none' }} data-pilotiq-builder-row="hidden">
882
- <input type="hidden" name={`${name}.${index}.__id`} value={row.id} readOnly />
883
- <input type="hidden" name={`${name}.${index}.type`} value={row.type} readOnly />
884
- <SchemaRenderer elements={namespaced} />
885
- </div>
889
+ <RowCoordsContext.Provider value={rowCoords}>
890
+ <div style={{ display: 'none' }} data-pilotiq-builder-row="hidden">
891
+ <input type="hidden" name={`${name}.${index}.__id`} value={row.id} readOnly />
892
+ <input type="hidden" name={`${name}.${index}.type`} value={row.type} readOnly />
893
+ <SchemaRenderer elements={namespaced} />
894
+ </div>
895
+ </RowCoordsContext.Provider>
886
896
  )
887
897
  }
888
898
 
@@ -921,6 +931,7 @@ function BuilderRow({
921
931
  const innerColumns = block.columns && block.columns > 1 ? block.columns : 1
922
932
 
923
933
  return (
934
+ <RowCoordsContext.Provider value={rowCoords}>
924
935
  <div
925
936
  className={`rounded-md border bg-card transition-opacity ${isDragging ? 'opacity-50' : ''}`}
926
937
  data-pilotiq-builder-row=""
@@ -999,6 +1010,7 @@ function BuilderRow({
999
1010
  : <SchemaRenderer elements={namespaced} />}
1000
1011
  </div>
1001
1012
  </div>
1013
+ </RowCoordsContext.Provider>
1002
1014
  )
1003
1015
  }
1004
1016
 
@@ -8,9 +8,10 @@ import {
8
8
  import { useFieldState } from '../FormStateContext.js'
9
9
  import { useCollabRoom } from '../CollabRoomContext.js'
10
10
  import { getCollabTextRenderer, type CollabTextRenderer } from '../CollabTextRendererRegistry.js'
11
+ import { useRowCoords } from '../RowCoordsContext.js'
12
+ import { parseRowFieldPath } from '../formStateHelpers.js'
11
13
  import { useToast } from '../Toaster.js'
12
14
  import { Button } from '../ui/button.js'
13
- import { computeDelta, preserveCursor } from './textDelta.js'
14
15
 
15
16
  type ToolbarButton =
16
17
  | 'bold' | 'italic' | 'strike' | 'link'
@@ -50,24 +51,37 @@ export function MarkdownInput({
50
51
  const fs = useFieldState(name)
51
52
  const room = useCollabRoom()
52
53
  const collabRenderer = getCollabTextRenderer()
53
-
54
- // Phase B follow-up — Tiptap-backed plain-text editor for markdown source
55
- // when collab is on. Same architectural fix as `TextLikeInput`'s
56
- // CollabTextField: y-prosemirror's `RelativePosition` cursor anchoring
57
- // replaces the broken `Y.Text` + `computeDelta` + `preserveCursor` heuristic.
54
+ const rowCoords = useRowCoords()
55
+
56
+ // Tiptap-backed plain-text editor for markdown source when collab is on.
57
+ // Same architectural fix as `TextLikeInput`'s `CollabTextField`:
58
+ // y-prosemirror's `RelativePosition` cursor anchoring against a
59
+ // `Y.XmlFragment` replaces whole-string LWW. Row leaves get the
60
+ // composite-key transform via `useRowCoords()` + `parseRowFieldPath`
61
+ // (same shape as TextLikeInput) so the fragment survives row reorders.
58
62
  //
59
- // Tradeoff: the markdown toolbar + Cmd-shortcuts + paste-image upload all
60
- // operate on a `<textarea>`'s DOM selection — they don't have a way to
61
- // reach into the Tiptap editor's selection without exposing the editor
62
- // instance, which would widen the renderer seam. For now those features
63
+ // Tradeoff: the markdown toolbar + Cmd-shortcuts + paste-image upload
64
+ // all operate on a `<textarea>`'s DOM selection — they don't have a
65
+ // way to reach into the Tiptap editor's selection without exposing the
66
+ // editor instance, which would widen the renderer seam. Those features
63
67
  // are write-mode-only on the native path; collab users type markdown
64
68
  // syntax directly (`**bold**`, `## heading`). The preview tab keeps
65
- // working since it reads `value` from local state.
66
- if (room && collabRenderer) {
69
+ // working since `MarkdownCollabInput` maintains a local mirror.
70
+ const fragmentKey: string | null = (() => {
71
+ if (!name.includes('.')) return name
72
+ if (!rowCoords) return null
73
+ const parsed = parseRowFieldPath(name)
74
+ if (!parsed) return null
75
+ if (parsed.arrayName !== rowCoords.arrayName) return null
76
+ if (parsed.index !== rowCoords.rowIndex) return null
77
+ return `${rowCoords.arrayName}.${rowCoords.rowId}.${parsed.fieldName}`
78
+ })()
79
+ if (room && collabRenderer && fragmentKey !== null) {
67
80
  return (
68
81
  <MarkdownCollabInput
69
82
  Renderer={collabRenderer}
70
- name={name}
83
+ fragmentKey={fragmentKey}
84
+ hiddenInputName={name}
71
85
  defaultValue={defaultValue}
72
86
  disabled={disabled}
73
87
  {...(placeholder !== undefined ? { placeholder } : {})}
@@ -79,100 +93,15 @@ export function MarkdownInput({
79
93
 
80
94
  const { notify } = useToast()
81
95
  const textareaRef = useRef<HTMLTextAreaElement | null>(null)
82
- // Phase F.6 — IME composition gate. Set between `compositionstart` /
83
- // `compositionend`; the textarea's onChange skips `applyDelta` while
84
- // composing so intermediate chars don't emit ops. Lives at the
85
- // component scope so the onChange and composition handlers share it.
86
- const isComposingRef = useRef<boolean>(false)
87
96
 
88
97
  const initial = useMemo(() => stringValue(defaultValue), [])
89
98
  const [localValue, setLocalValue] = useState<string>(initial)
90
99
  const [tab, setTab] = useState<'write' | 'preview'>('write')
91
100
  const [busy, setBusy] = useState(false)
92
101
 
93
- // Phase F.6 when a `<RecordCollabRoom>` is mounted and the field has
94
- // a `TextBinding`, the textarea is bound to a `Y.Text` and edits emit
95
- // `TextDelta`s. Mirrors the architecture in `TextLikeInput.tsx` but
96
- // wired in-line because MarkdownInput has its own toolbar + Preview
97
- // tab that also need to flow through the binding.
98
- const binding = fs.textBinding
99
- const [boundValue, setBoundValue] = useState<string>(() => binding?.read() ?? initial)
100
- const boundValueRef = useRef<string>(boundValue)
101
- useEffect(() => { boundValueRef.current = boundValue }, [boundValue])
102
-
103
- // On binding swap: read current Y.Text state. If non-empty, lift it
104
- // into local + form-map state. If empty (no peer has typed yet), leave
105
- // the SSR-default-derived `boundValue` showing — first edit will
106
- // emit a replace-from-empty delta that atomically populates Y.Text.
107
- // No client-side seed: Y.Text isn't safe to seed under concurrent
108
- // first-mounters (see @pilotiq-pro/collab `formCollabBinding.ts`).
109
- useEffect(() => {
110
- if (!binding) return
111
- const next = binding.read()
112
- if (next.length > 0) {
113
- setBoundValue(next)
114
- boundValueRef.current = next
115
- fs.setValue(next)
116
- }
117
- // eslint-disable-next-line react-hooks/exhaustive-deps
118
- }, [binding])
119
-
120
- // Subscribe to remote changes. Local-echoes are filtered by the
121
- // `next === prev` guard. Cursor preserved via the same heuristic
122
- // used in `TextLikeInput.BoundTextInput`.
123
- useEffect(() => {
124
- if (!binding) return
125
- return binding.observe((next) => {
126
- const prev = boundValueRef.current
127
- if (next === prev) return
128
- const ta = textareaRef.current
129
- const cursor = ta?.selectionStart ?? next.length
130
- const restored = preserveCursor(prev, next, cursor)
131
- setBoundValue(next)
132
- boundValueRef.current = next
133
- fs.setValue(next)
134
- requestAnimationFrame(() => {
135
- if (!ta) return
136
- if (document.activeElement !== ta) return
137
- try { ta.setSelectionRange(restored, restored) } catch { /* defensive */ }
138
- })
139
- })
140
- // eslint-disable-next-line react-hooks/exhaustive-deps
141
- }, [binding])
142
-
143
- const value = binding
144
- ? boundValue
145
- : (fs.controlled ? stringValue(fs.value) : localValue)
102
+ const value = fs.controlled ? stringValue(fs.value) : localValue
146
103
 
147
104
  const setValue = (next: string): void => {
148
- if (binding) {
149
- // Compute against current Y.Text contents (not the local ref) so:
150
- // - first edit against empty Y.Text → `insert@0 <whole>` atomic
151
- // populate (no separate seed op needed);
152
- // - after a remote-applied update or server-resolve replace, the
153
- // delta reflects the actual current shared state, not stale
154
- // local bookkeeping.
155
- const before = binding.read()
156
- if (next !== before) {
157
- const delta = computeDelta(before, next)
158
- // Pre-stamp `boundValueRef.current = next` BEFORE `applyDelta`.
159
- // Y.Text's `observe` fires synchronously inside `applyDelta` for
160
- // our own write; without this the observer would see
161
- // `prev=before, next=after` and run `preserveCursor` — designed
162
- // for *remote* edits — which clobbers the user's caret on local
163
- // typing (scrambled output on mid-string inserts). With
164
- // `boundValueRef` already at `next`, the observer's
165
- // `next === prev` short-circuit fires and the cursor is left
166
- // alone for local echoes. Mirror of the same fix in
167
- // `BoundTextInput.commitDelta`.
168
- boundValueRef.current = next
169
- if (delta) binding.applyDelta(delta)
170
- setBoundValue(next)
171
- }
172
- fs.setValue(next)
173
- fs.triggerLive(next)
174
- return
175
- }
176
105
  if (fs.controlled) { fs.setValue(next); fs.triggerLive(next) }
177
106
  else { setLocalValue(next); fs.triggerLive(next) }
178
107
  }
@@ -405,24 +334,7 @@ export function MarkdownInput({
405
334
  {...(fs.controlled
406
335
  ? {
407
336
  value,
408
- onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => {
409
- // Phase F.6 — when the binding is active and the user
410
- // is mid-IME, paint locally and hold the delta until
411
- // compositionend so we never emit ops for the
412
- // intermediate composing chars.
413
- if (binding && isComposingRef.current) {
414
- setBoundValue(e.target.value)
415
- return
416
- }
417
- setValue(e.target.value)
418
- },
419
- ...(binding ? {
420
- onCompositionStart: () => { isComposingRef.current = true },
421
- onCompositionEnd: (e: React.CompositionEvent<HTMLTextAreaElement>) => {
422
- isComposingRef.current = false
423
- setValue(e.currentTarget.value)
424
- },
425
- } : {}),
337
+ onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => setValue(e.target.value),
426
338
  }
427
339
  : { defaultValue: initial, onChange: (e) => setLocalValue(e.target.value) })}
428
340
  onPaste={onPaste}
@@ -472,17 +384,18 @@ function TabButton({ active, onClick, children }: {
472
384
  * load-bearing change; markdown-syntax authors keep typing as before.
473
385
  */
474
386
  function MarkdownCollabInput({
475
- Renderer, name, defaultValue, disabled, placeholder, minHeight, maxHeight,
387
+ Renderer, fragmentKey, hiddenInputName, defaultValue, disabled, placeholder, minHeight, maxHeight,
476
388
  }: {
477
- Renderer: CollabTextRenderer
478
- name: string
479
- defaultValue: unknown
480
- disabled: boolean
481
- placeholder?: string
482
- minHeight?: string
483
- maxHeight?: string
389
+ Renderer: CollabTextRenderer
390
+ fragmentKey: string
391
+ hiddenInputName: string
392
+ defaultValue: unknown
393
+ disabled: boolean
394
+ placeholder?: string
395
+ minHeight?: string
396
+ maxHeight?: string
484
397
  }): React.ReactElement {
485
- const fs = useFieldState(name)
398
+ const fs = useFieldState(hiddenInputName)
486
399
  const initial = useMemo(() => stringValue(defaultValue), [])
487
400
  const [text, setText] = useState<string>(initial)
488
401
  const [tab, setTab] = useState<'write' | 'preview'>('write')
@@ -513,9 +426,9 @@ function MarkdownCollabInput({
513
426
  </div>
514
427
  {tab === 'write' ? (
515
428
  <div style={wrapperStyle} className="overflow-auto">
516
- <input type="hidden" name={name} value={text} />
429
+ <input type="hidden" name={hiddenInputName} value={text} />
517
430
  <Renderer
518
- name={name}
431
+ name={fragmentKey}
519
432
  multiline={true}
520
433
  defaultValue={initial}
521
434
  {...(placeholder !== undefined ? { placeholder } : {})}
@@ -527,7 +440,7 @@ function MarkdownCollabInput({
527
440
  </div>
528
441
  ) : (
529
442
  <>
530
- <input type="hidden" name={name} value={text} readOnly />
443
+ <input type="hidden" name={hiddenInputName} value={text} readOnly />
531
444
  <div
532
445
  className="prose prose-sm dark:prose-invert max-w-none px-3 py-2"
533
446
  style={wrapperStyle}
@@ -5,6 +5,7 @@ import { Button } from '../ui/button.js'
5
5
  import { SchemaRenderer, dispatchHandlerAction } from '../SchemaRenderer.js'
6
6
  import { FormIdContext, useFormState, useRowBinding } from '../FormStateContext.js'
7
7
  import { findFieldMeta } from '../formStateHelpers.js'
8
+ import { RowCoordsContext } from '../RowCoordsContext.js'
8
9
  import { useNavigate } from '../navigate.js'
9
10
  import { useToast } from '../Toaster.js'
10
11
  import type { RowButtonsMeta } from '../../fields/RowButton.js'
@@ -243,16 +244,14 @@ export function RepeaterInput({
243
244
  // it when present so peers see the same lifecycle events; absent =
244
245
  // today's local-only behaviour, unchanged.
245
246
  const rowBinding = useRowBinding(name)
246
- // Phase F.5c — mirror row identities into the form's values map so dotted
247
- // row-leaf consumers (`useFieldState('${name}.${i}.heading').textBinding`)
248
- // can resolve the row's `__id` via `rowIdAtIndex(ctx.values, name, i)`.
249
- // The renderer is the only source of truth for `(index → rowId)`; without
250
- // this stamp the F.5c per-row Y.Text path stays null and row text fields
251
- // never sync. Setting a `__id` key routes through `routeBindingWrite` →
252
- // `parseRowFieldPath` which filters `__id` no-op on the binding side
253
- // (no Y.Text writes), so the only effect is a row in `valuesState`.
254
- // `formStateForIds` mirrors `formState` below; we read via `useFormState()`
255
- // here too instead of forward-referencing the later binding.
247
+ // Mirror row identities into the form's values map so dotted row-leaf
248
+ // consumers can resolve the row's `__id` via `rowIdAtIndex(ctx.values,
249
+ // name, i)`. Setting a `__id` key routes through `routeBindingWrite`
250
+ // `parseRowFieldPath` which filters `__id` no-op on the binding side,
251
+ // so the only effect is a row entry landing in `valuesState`.
252
+ // `formStateForIds` mirrors `formState` below; we read via
253
+ // `useFormState()` here too instead of forward-referencing the later
254
+ // binding.
256
255
  const formStateForIds = useFormState()
257
256
  const ctxSetValue = formStateForIds?.setValue
258
257
  useEffect(() => {
@@ -749,16 +748,25 @@ function RepeaterRow({
749
748
  [row.children, prefix],
750
749
  )
751
750
  const headerLabel = row.itemLabel ?? `Item ${index + 1}`
751
+ // Row coords for dotted-path text leaves — composes fragment-key
752
+ // `${arrayName}.${rowId}.${fieldName}` for the Tiptap-backed collab
753
+ // renderer (see collab-row-text-tiptap-backed.md Phase 1).
754
+ const rowCoords = useMemo(
755
+ () => ({ arrayName: name, rowIndex: index, rowId: row.id }),
756
+ [name, index, row.id],
757
+ )
752
758
 
753
759
  // Hidden rows: render only the inputs (and __id) inside a display:none
754
760
  // wrapper so their values round-trip through FormData on submit. No
755
761
  // chrome, no drag wiring, no labels — `itemHidden` is purely UX.
756
762
  if (row.hidden) {
757
763
  return (
758
- <div style={{ display: 'none' }} data-pilotiq-repeater-row="hidden">
759
- <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
760
- <SchemaRenderer elements={namespaced} />
761
- </div>
764
+ <RowCoordsContext.Provider value={rowCoords}>
765
+ <div style={{ display: 'none' }} data-pilotiq-repeater-row="hidden">
766
+ <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
767
+ <SchemaRenderer elements={namespaced} />
768
+ </div>
769
+ </RowCoordsContext.Provider>
762
770
  )
763
771
  }
764
772
 
@@ -795,6 +803,7 @@ function RepeaterRow({
795
803
  // wrapping in a class that hides the FieldShell's label slot.
796
804
  if (simple) {
797
805
  return (
806
+ <RowCoordsContext.Provider value={rowCoords}>
798
807
  <div
799
808
  className={`flex items-center gap-2 transition-opacity ${isDragging ? 'opacity-50' : ''}`}
800
809
  data-pilotiq-repeater-row="simple"
@@ -830,10 +839,12 @@ function RepeaterRow({
830
839
  />
831
840
  )}
832
841
  </div>
842
+ </RowCoordsContext.Provider>
833
843
  )
834
844
  }
835
845
 
836
846
  return (
847
+ <RowCoordsContext.Provider value={rowCoords}>
837
848
  <div
838
849
  className={`rounded-md border bg-card transition-opacity ${isDragging ? 'opacity-50' : ''}`}
839
850
  data-pilotiq-repeater-row=""
@@ -910,6 +921,7 @@ function RepeaterRow({
910
921
  : <SchemaRenderer elements={namespaced} />}
911
922
  </div>
912
923
  </div>
924
+ </RowCoordsContext.Provider>
913
925
  )
914
926
  }
915
927
 
@@ -1168,6 +1180,10 @@ function RepeaterTableRow({
1168
1180
  () => row.children.map(c => prefixFieldNames(c, prefix)),
1169
1181
  [row.children, prefix],
1170
1182
  )
1183
+ const rowCoords = useMemo(
1184
+ () => ({ arrayName: name, rowIndex: index, rowId: row.id }),
1185
+ [name, index, row.id],
1186
+ )
1171
1187
 
1172
1188
  if (row.hidden) {
1173
1189
  // Render the hidden envelope as a single full-span cell so column
@@ -1175,11 +1191,18 @@ function RepeaterTableRow({
1175
1191
  // still in the form's submit. Using `<tr style="display:none">`
1176
1192
  // would warn under React strict-mode in Firefox; the wrapping cell
1177
1193
  // keeps the markup HTML-valid.
1194
+ //
1195
+ // The provider wraps the cell rather than the `<tr>` because React's
1196
+ // table-row whitelisting only accepts `<th>/<td>` children, not a
1197
+ // context provider; the provider is a no-DOM wrapper so it sits
1198
+ // inside the cell fine.
1178
1199
  return (
1179
1200
  <tr style={{ display: 'none' }} data-pilotiq-repeater-row="hidden">
1180
1201
  <td colSpan={columns.length + 1}>
1181
- <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
1182
- <SchemaRenderer elements={namespaced} />
1202
+ <RowCoordsContext.Provider value={rowCoords}>
1203
+ <input type="hidden" name={`${prefix}.__id`} value={row.id} readOnly />
1204
+ <SchemaRenderer elements={namespaced} />
1205
+ </RowCoordsContext.Provider>
1183
1206
  </td>
1184
1207
  </tr>
1185
1208
  )
@@ -1209,6 +1232,7 @@ function RepeaterTableRow({
1209
1232
  : {}
1210
1233
 
1211
1234
  return (
1235
+ <RowCoordsContext.Provider value={rowCoords}>
1212
1236
  <tr
1213
1237
  className={`border-t align-top ${isDragging ? 'opacity-50' : ''}`}
1214
1238
  data-pilotiq-repeater-row=""
@@ -1265,6 +1289,7 @@ function RepeaterTableRow({
1265
1289
  </div>
1266
1290
  </td>
1267
1291
  </tr>
1292
+ </RowCoordsContext.Provider>
1268
1293
  )
1269
1294
  }
1270
1295