@pilotiq/pilotiq 0.6.2 → 0.7.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 (197) hide show
  1. package/.turbo/turbo-build.log +6 -2
  2. package/CHANGELOG.md +608 -0
  3. package/CLAUDE.md +6 -5
  4. package/dist/Column.d.ts +35 -0
  5. package/dist/Column.d.ts.map +1 -1
  6. package/dist/Column.js +41 -0
  7. package/dist/Column.js.map +1 -1
  8. package/dist/Page.d.ts +13 -4
  9. package/dist/Page.d.ts.map +1 -1
  10. package/dist/Page.js +9 -2
  11. package/dist/Page.js.map +1 -1
  12. package/dist/Pilotiq.d.ts +84 -0
  13. package/dist/Pilotiq.d.ts.map +1 -1
  14. package/dist/Pilotiq.js +66 -0
  15. package/dist/Pilotiq.js.map +1 -1
  16. package/dist/Resource.d.ts +26 -0
  17. package/dist/Resource.d.ts.map +1 -1
  18. package/dist/Resource.js +9 -0
  19. package/dist/Resource.js.map +1 -1
  20. package/dist/actions/exportFactory.js +1 -1
  21. package/dist/actions/exportFactory.js.map +1 -1
  22. package/dist/columns/SelectColumn.d.ts +32 -5
  23. package/dist/columns/SelectColumn.d.ts.map +1 -1
  24. package/dist/columns/SelectColumn.js +37 -7
  25. package/dist/columns/SelectColumn.js.map +1 -1
  26. package/dist/defaultPages.d.ts.map +1 -1
  27. package/dist/defaultPages.js +3 -0
  28. package/dist/defaultPages.js.map +1 -1
  29. package/dist/elements/Form.d.ts +17 -0
  30. package/dist/elements/Form.d.ts.map +1 -1
  31. package/dist/elements/Form.js +17 -0
  32. package/dist/elements/Form.js.map +1 -1
  33. package/dist/elements/Table.d.ts +26 -0
  34. package/dist/elements/Table.d.ts.map +1 -1
  35. package/dist/elements/Table.js +15 -1
  36. package/dist/elements/Table.js.map +1 -1
  37. package/dist/elements/TableGroup.d.ts +84 -0
  38. package/dist/elements/TableGroup.d.ts.map +1 -1
  39. package/dist/elements/TableGroup.js +103 -0
  40. package/dist/elements/TableGroup.js.map +1 -1
  41. package/dist/elements/dispatchForm.d.ts.map +1 -1
  42. package/dist/elements/dispatchForm.js +36 -6
  43. package/dist/elements/dispatchForm.js.map +1 -1
  44. package/dist/elements/dispatchTable.d.ts +12 -0
  45. package/dist/elements/dispatchTable.d.ts.map +1 -1
  46. package/dist/elements/dispatchTable.js +103 -28
  47. package/dist/elements/dispatchTable.js.map +1 -1
  48. package/dist/fields/Field.d.ts +7 -2
  49. package/dist/fields/Field.d.ts.map +1 -1
  50. package/dist/fields/Field.js +8 -3
  51. package/dist/fields/Field.js.map +1 -1
  52. package/dist/fields/RepeaterField.d.ts +65 -0
  53. package/dist/fields/RepeaterField.d.ts.map +1 -1
  54. package/dist/fields/RepeaterField.js +48 -0
  55. package/dist/fields/RepeaterField.js.map +1 -1
  56. package/dist/orm/modelDefaults.d.ts.map +1 -1
  57. package/dist/orm/modelDefaults.js +19 -0
  58. package/dist/orm/modelDefaults.js.map +1 -1
  59. package/dist/pageData.d.ts +20 -0
  60. package/dist/pageData.d.ts.map +1 -1
  61. package/dist/pageData.js +242 -34
  62. package/dist/pageData.js.map +1 -1
  63. package/dist/react/AppShell.d.ts +17 -1
  64. package/dist/react/AppShell.d.ts.map +1 -1
  65. package/dist/react/AppShell.js +34 -3
  66. package/dist/react/AppShell.js.map +1 -1
  67. package/dist/react/PendingSuggestionApplierRegistry.d.ts +34 -0
  68. package/dist/react/PendingSuggestionApplierRegistry.d.ts.map +1 -0
  69. package/dist/react/PendingSuggestionApplierRegistry.js +51 -0
  70. package/dist/react/PendingSuggestionApplierRegistry.js.map +1 -0
  71. package/dist/react/PendingSuggestionOverlayRegistry.d.ts +46 -0
  72. package/dist/react/PendingSuggestionOverlayRegistry.d.ts.map +1 -0
  73. package/dist/react/PendingSuggestionOverlayRegistry.js +16 -0
  74. package/dist/react/PendingSuggestionOverlayRegistry.js.map +1 -0
  75. package/dist/react/PendingSuggestionsContext.d.ts +153 -0
  76. package/dist/react/PendingSuggestionsContext.d.ts.map +1 -0
  77. package/dist/react/PendingSuggestionsContext.js +46 -0
  78. package/dist/react/PendingSuggestionsContext.js.map +1 -0
  79. package/dist/react/SchemaRenderer.d.ts.map +1 -1
  80. package/dist/react/SchemaRenderer.js +312 -39
  81. package/dist/react/SchemaRenderer.js.map +1 -1
  82. package/dist/react/cells/EditableCell.d.ts +8 -0
  83. package/dist/react/cells/EditableCell.d.ts.map +1 -1
  84. package/dist/react/cells/EditableCell.js +6 -2
  85. package/dist/react/cells/EditableCell.js.map +1 -1
  86. package/dist/react/fields/CheckboxListInput.d.ts.map +1 -1
  87. package/dist/react/fields/CheckboxListInput.js +29 -2
  88. package/dist/react/fields/CheckboxListInput.js.map +1 -1
  89. package/dist/react/fields/ColorInput.d.ts.map +1 -1
  90. package/dist/react/fields/ColorInput.js +28 -2
  91. package/dist/react/fields/ColorInput.js.map +1 -1
  92. package/dist/react/fields/DateTimeInput.d.ts.map +1 -1
  93. package/dist/react/fields/DateTimeInput.js +28 -2
  94. package/dist/react/fields/DateTimeInput.js.map +1 -1
  95. package/dist/react/fields/FieldShell.d.ts.map +1 -1
  96. package/dist/react/fields/FieldShell.js +161 -3
  97. package/dist/react/fields/FieldShell.js.map +1 -1
  98. package/dist/react/fields/FileUploadInput.d.ts.map +1 -1
  99. package/dist/react/fields/FileUploadInput.js +27 -2
  100. package/dist/react/fields/FileUploadInput.js.map +1 -1
  101. package/dist/react/fields/KeyValueInput.d.ts.map +1 -1
  102. package/dist/react/fields/KeyValueInput.js +33 -2
  103. package/dist/react/fields/KeyValueInput.js.map +1 -1
  104. package/dist/react/fields/RadioInput.d.ts.map +1 -1
  105. package/dist/react/fields/RadioInput.js +28 -2
  106. package/dist/react/fields/RadioInput.js.map +1 -1
  107. package/dist/react/fields/SelectFieldInput.d.ts.map +1 -1
  108. package/dist/react/fields/SelectFieldInput.js +31 -2
  109. package/dist/react/fields/SelectFieldInput.js.map +1 -1
  110. package/dist/react/fields/SliderInput.d.ts.map +1 -1
  111. package/dist/react/fields/SliderInput.js +26 -2
  112. package/dist/react/fields/SliderInput.js.map +1 -1
  113. package/dist/react/fields/TagsInput.d.ts.map +1 -1
  114. package/dist/react/fields/TagsInput.js +26 -2
  115. package/dist/react/fields/TagsInput.js.map +1 -1
  116. package/dist/react/fields/ToggleFieldInput.d.ts.map +1 -1
  117. package/dist/react/fields/ToggleFieldInput.js +29 -2
  118. package/dist/react/fields/ToggleFieldInput.js.map +1 -1
  119. package/dist/react/index.d.ts +3 -0
  120. package/dist/react/index.d.ts.map +1 -1
  121. package/dist/react/index.js +3 -0
  122. package/dist/react/index.js.map +1 -1
  123. package/dist/routes.d.ts.map +1 -1
  124. package/dist/routes.js +55 -2
  125. package/dist/routes.js.map +1 -1
  126. package/dist/schema/Section.d.ts +16 -0
  127. package/dist/schema/Section.d.ts.map +1 -1
  128. package/dist/schema/Section.js +16 -0
  129. package/dist/schema/Section.js.map +1 -1
  130. package/dist/schema/Wizard.d.ts +45 -0
  131. package/dist/schema/Wizard.d.ts.map +1 -1
  132. package/dist/schema/Wizard.js +50 -0
  133. package/dist/schema/Wizard.js.map +1 -1
  134. package/dist/schema/resolveSchema.d.ts +8 -0
  135. package/dist/schema/resolveSchema.d.ts.map +1 -1
  136. package/dist/schema/resolveSchema.js +70 -1
  137. package/dist/schema/resolveSchema.js.map +1 -1
  138. package/dist/sessionFilters.d.ts.map +1 -1
  139. package/dist/sessionFilters.js +12 -1
  140. package/dist/sessionFilters.js.map +1 -1
  141. package/dist/styles/file-upload.css +13 -0
  142. package/dist/vite.d.ts.map +1 -1
  143. package/dist/vite.js +9 -2
  144. package/dist/vite.js.map +1 -1
  145. package/package.json +6 -4
  146. package/src/Column.test.ts +36 -0
  147. package/src/Column.ts +54 -0
  148. package/src/Page.ts +13 -4
  149. package/src/Pilotiq.ts +109 -0
  150. package/src/Resource.ts +29 -0
  151. package/src/actions/exportFactory.ts +1 -1
  152. package/src/columns/SelectColumn.ts +46 -8
  153. package/src/columns/editableColumns.test.ts +45 -0
  154. package/src/defaultPages.ts +3 -0
  155. package/src/elements/Form.ts +19 -0
  156. package/src/elements/Table.ts +35 -1
  157. package/src/elements/TableGroup.test.ts +111 -0
  158. package/src/elements/TableGroup.ts +135 -0
  159. package/src/elements/dispatchForm.ts +34 -7
  160. package/src/elements/dispatchTable.test.ts +267 -0
  161. package/src/elements/dispatchTable.ts +111 -32
  162. package/src/fields/Field.test.ts +15 -0
  163. package/src/fields/Field.ts +8 -3
  164. package/src/fields/RepeaterField.ts +104 -0
  165. package/src/fields/RepeaterRelationship.test.ts +173 -0
  166. package/src/nestedRelationManagerData.test.ts +21 -0
  167. package/src/orm/modelDefaults.ts +21 -0
  168. package/src/pageData.ts +267 -47
  169. package/src/react/AppShell.tsx +55 -4
  170. package/src/react/PendingSuggestionApplierRegistry.ts +80 -0
  171. package/src/react/PendingSuggestionOverlayRegistry.ts +54 -0
  172. package/src/react/PendingSuggestionsContext.tsx +172 -0
  173. package/src/react/SchemaRenderer.tsx +504 -95
  174. package/src/react/cells/EditableCell.tsx +11 -2
  175. package/src/react/fields/CheckboxListInput.tsx +23 -2
  176. package/src/react/fields/ColorInput.tsx +22 -2
  177. package/src/react/fields/DateTimeInput.tsx +22 -2
  178. package/src/react/fields/FieldShell.tsx +167 -3
  179. package/src/react/fields/FileUploadInput.tsx +21 -2
  180. package/src/react/fields/KeyValueInput.tsx +32 -2
  181. package/src/react/fields/RadioInput.tsx +23 -2
  182. package/src/react/fields/SelectFieldInput.tsx +25 -2
  183. package/src/react/fields/SliderInput.tsx +20 -2
  184. package/src/react/fields/TagsInput.tsx +20 -2
  185. package/src/react/fields/ToggleFieldInput.tsx +23 -2
  186. package/src/react/index.ts +18 -0
  187. package/src/relationManagerData.test.ts +451 -2
  188. package/src/routes.ts +58 -2
  189. package/src/schema/Section.ts +17 -0
  190. package/src/schema/Wizard.ts +67 -0
  191. package/src/schema/containers.test.ts +90 -0
  192. package/src/schema/resolveSchema.test.ts +50 -0
  193. package/src/schema/resolveSchema.ts +79 -1
  194. package/src/sessionFilters.test.ts +23 -0
  195. package/src/sessionFilters.ts +11 -1
  196. package/src/styles/file-upload.css +13 -0
  197. package/src/vite.ts +9 -2
@@ -33,6 +33,11 @@ export interface EditableCellProps {
33
33
  value: unknown
34
34
  /** Disabled = static `disabled()` OR per-row `_cellDisabled[col]`. */
35
35
  disabled: boolean
36
+ /** Row-scoped option override stamped by `loadTableRecords` when the
37
+ * column is a `SelectColumn` with a per-row `.options(record => …)`
38
+ * resolver. Wins over `col.selectOptions`; absent when the resolver
39
+ * threw or the column has only static options. */
40
+ rowOptions?: Array<{ value: string; label: string }>
36
41
  }
37
42
 
38
43
  interface PatchResultOk {
@@ -283,9 +288,13 @@ export function CellToggle(props: EditableCellProps): React.ReactElement {
283
288
  // ─── Select cell ───────────────────────────────────────
284
289
 
285
290
  export function CellSelect(props: EditableCellProps): React.ReactElement {
286
- const { url, value, disabled, col } = props
291
+ const { url, value, disabled, col, rowOptions } = props
292
+ // Per-row resolver wins over the column's static options. Both can be
293
+ // present: a column may set static options as a fallback that the
294
+ // resolver overrides per row.
287
295
  const opts: Array<{ value: string; label: string }> =
288
- Array.isArray(col['selectOptions']) ? col['selectOptions'] as Array<{ value: string; label: string }> : []
296
+ rowOptions ??
297
+ (Array.isArray(col['selectOptions']) ? col['selectOptions'] as Array<{ value: string; label: string }> : [])
289
298
  const nullable = col['selectNullable'] === true
290
299
  const showPlaceholderOnce = col['selectablePlaceholder'] !== false // default true (keep showing)
291
300
  const confirmMsg = col['confirm'] as string | undefined
@@ -1,5 +1,6 @@
1
- import React, { useState } from 'react'
2
- import { useFieldState } from '../FormStateContext.js'
1
+ import React, { useContext, useEffect, useRef, useState } from 'react'
2
+ import { useFieldState, FormIdContext } from '../FormStateContext.js'
3
+ import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
3
4
  import { Checkbox } from '../ui/checkbox.js'
4
5
 
5
6
  /**
@@ -39,6 +40,26 @@ export function CheckboxListInput({
39
40
  else { setLocalValue(next); fs.triggerLive(next) }
40
41
  }
41
42
 
43
+ // Cross-tree applier — the visible checkboxes are React-controlled
44
+ // (Base UI `<Checkbox checked={…}>`); the per-option hidden mirrors
45
+ // share the `[name]` attribute, so FieldShell's generic applier would
46
+ // overwrite every one of them with the suggestion's stringified value
47
+ // instead of replacing the array. FieldShell skips its generic
48
+ // registration for fieldType === 'checkboxList'.
49
+ const fsRef = useRef(fs)
50
+ useEffect(() => { fsRef.current = fs }, [fs])
51
+ const formId = useContext(FormIdContext) || undefined
52
+ useEffect(() => {
53
+ if (name.includes('.')) return
54
+ const applier: PendingSuggestionApplier = (suggestion) => {
55
+ const next = toArray(suggestion.suggestedValue)
56
+ const cur = fsRef.current
57
+ if (cur.controlled) { cur.setValue(next); cur.triggerLive(next) }
58
+ else { setLocalValue(next); cur.triggerLive(next) }
59
+ }
60
+ return registerPendingSuggestionApplier(formId, name, applier)
61
+ }, [name, formId])
62
+
42
63
  const layout = columns > 1
43
64
  ? `grid grid-cols-${columns} gap-2`
44
65
  : 'flex flex-col gap-2'
@@ -1,5 +1,6 @@
1
- import React, { useState } from 'react'
2
- import { useFieldState } from '../FormStateContext.js'
1
+ import React, { useContext, useEffect, useRef, useState } from 'react'
2
+ import { useFieldState, FormIdContext } from '../FormStateContext.js'
3
+ import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
3
4
  import { Input } from '../ui/input.js'
4
5
 
5
6
  /**
@@ -27,6 +28,25 @@ export function ColorInput({
27
28
  else { setLocalValue(v); fs.triggerLive(v) }
28
29
  }
29
30
 
31
+ // Cross-tree applier — color/text inputs are React-controlled (`value`,
32
+ // not `defaultValue`), so a DOM-write to the hidden mirror wouldn't
33
+ // reach them. FieldShell skips its generic registration for
34
+ // fieldType === 'color'.
35
+ const fsRef = useRef(fs)
36
+ useEffect(() => { fsRef.current = fs }, [fs])
37
+ const formId = useContext(FormIdContext) || undefined
38
+ useEffect(() => {
39
+ if (name.includes('.')) return
40
+ const applier: PendingSuggestionApplier = (suggestion) => {
41
+ const raw = suggestion.suggestedValue
42
+ const next = typeof raw === 'string' && raw ? raw : '#000000'
43
+ const cur = fsRef.current
44
+ if (cur.controlled) { cur.setValue(next); cur.triggerLive(next) }
45
+ else { setLocalValue(next); cur.triggerLive(next) }
46
+ }
47
+ return registerPendingSuggestionApplier(formId, name, applier)
48
+ }, [name, formId])
49
+
30
50
  return (
31
51
  <div className="flex items-center gap-2">
32
52
  <input type="hidden" name={name} value={value} />
@@ -1,5 +1,6 @@
1
- import React, { useState } from 'react'
2
- import { useFieldState } from '../FormStateContext.js'
1
+ import React, { useContext, useEffect, useRef, useState } from 'react'
2
+ import { useFieldState, FormIdContext } from '../FormStateContext.js'
3
+ import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
3
4
  import { Input } from '../ui/input.js'
4
5
 
5
6
  /**
@@ -28,6 +29,25 @@ export function DateTimeInput({
28
29
  else { setLocalValue(v); fs.triggerLive(v) }
29
30
  }
30
31
 
32
+ // Cross-tree applier — the visible `<input type="datetime-local">` is
33
+ // React-controlled (`value`, not `defaultValue`), so a DOM-write to
34
+ // it bypasses the controller. FieldShell skips its generic
35
+ // registration for fieldType === 'dateTime'.
36
+ const fsRef = useRef(fs)
37
+ useEffect(() => { fsRef.current = fs }, [fs])
38
+ const formId = useContext(FormIdContext) || undefined
39
+ useEffect(() => {
40
+ if (name.includes('.')) return
41
+ const applier: PendingSuggestionApplier = (suggestion) => {
42
+ const v = suggestion.suggestedValue
43
+ const next = v == null || v === '' ? '' : String(v)
44
+ const cur = fsRef.current
45
+ if (cur.controlled) { cur.setValue(next); cur.triggerLive(next) }
46
+ else { setLocalValue(next); cur.triggerLive(next) }
47
+ }
48
+ return registerPendingSuggestionApplier(formId, name, applier)
49
+ }, [name, formId])
50
+
31
51
  return (
32
52
  <Input
33
53
  type="datetime-local"
@@ -1,6 +1,31 @@
1
- import React from 'react'
1
+ import React, { useContext, useEffect, useRef } from 'react'
2
2
  import type { ElementMeta } from '../../schema/Element.js'
3
3
  import { getIcon } from '../../icons/registry.js'
4
+ import { usePendingSuggestions, usePendingSuggestionsForField, type PendingSuggestion } from '../PendingSuggestionsContext.js'
5
+ import { getPendingSuggestionOverlay } from '../PendingSuggestionOverlayRegistry.js'
6
+ import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
7
+ import { FormIdContext, useFieldState } from '../FormStateContext.js'
8
+
9
+ /**
10
+ * Field types whose visible state is driven by React (not by a matching
11
+ * `[name]` DOM input). Each registers its own applier inside the field
12
+ * renderer; FieldShell skips its generic DOM-write applier for these so
13
+ * the field-owned applier stays last-write-wins in the registry. Keep in
14
+ * sync with the per-field `useEffect(registerPendingSuggestionApplier…)`
15
+ * blocks under `react/fields/`.
16
+ */
17
+ const SELF_APPLIER_FIELD_TYPES = new Set<string>([
18
+ 'select',
19
+ 'toggle',
20
+ 'slider',
21
+ 'color',
22
+ 'keyValue',
23
+ 'fileUpload',
24
+ 'tagsInput',
25
+ 'dateTime',
26
+ 'radio',
27
+ 'checkboxList',
28
+ ])
4
29
 
5
30
  /**
6
31
  * Shared chrome around every field input — label + required asterisk +
@@ -41,6 +66,77 @@ export function FieldShell({ el, name, label, required, children, before, after,
41
66
  const hiddenLabel = el['hiddenLabel'] === true
42
67
  const wrapperAttrs = pickWrapperAttrs(el)
43
68
 
69
+ // Pending-suggestion overlay (Plan 6/7). RichText fields render the diff
70
+ // inline inside the editor instead — they opt out via the hidden marker
71
+ // below. Other field types pick up the slot whenever a plugin (e.g.
72
+ // `@pilotiq-pro/ai`) has registered a renderer AND there's a matching
73
+ // suggestion in the queue.
74
+ const fieldType = el['fieldType'] as string | undefined
75
+ const isRichText = fieldType === 'richtext'
76
+ // Field types that drive their visible state from React (not from a
77
+ // matching `[name]` DOM input) register their own applier — see
78
+ // `SelectFieldInput` for the canonical example. FieldShell's generic
79
+ // DOM-write applier would silently no-op on these, and (since parent
80
+ // effects run AFTER children) would overwrite the field-owned applier
81
+ // in the registry. Skip registration here so the field-owned applier
82
+ // stays the winner.
83
+ const ownsApplier = fieldType !== undefined && SELF_APPLIER_FIELD_TYPES.has(fieldType)
84
+ const { list: pending, dismiss } = usePendingSuggestionsForField(name)
85
+ const { approve } = usePendingSuggestions()
86
+ const Overlay = isRichText ? null : getPendingSuggestionOverlay()
87
+ const overlaySuggestion = pending[0] ?? null
88
+ // Approve routes through the cross-tree applier registry (Phase 8.5)
89
+ // so field types that own their visible state (Select / Toggle / Slider /
90
+ // Color / etc.) get their registered applier — not the overlay's
91
+ // hardcoded `field.setValue` + DOM-write fallback, which would silently
92
+ // miss the React state of those custom components. Reject just dismisses
93
+ // and restores focus to the input so the user can keep typing without
94
+ // a stray click.
95
+ const onReject = (): void => {
96
+ dismiss(overlaySuggestion!.id)
97
+ if (typeof document === 'undefined') return
98
+ queueMicrotask(() => {
99
+ const el = document.getElementsByName(name)[0]
100
+ if (el instanceof HTMLElement) el.focus()
101
+ })
102
+ }
103
+ const overlayNode = Overlay && overlaySuggestion ? (
104
+ <Overlay
105
+ suggestion={overlaySuggestion}
106
+ onApprove={() => approve(overlaySuggestion.id)}
107
+ onReject={onReject}
108
+ {...(fieldType !== undefined ? { fieldType } : {})}
109
+ el={el}
110
+ />
111
+ ) : null
112
+
113
+ // Cross-tree applier registration (Phase 8.5). Lets aggregate consumers
114
+ // (e.g. a chat-sidebar pending-pill living outside the form's React
115
+ // tree) reach this field's mutator via
116
+ // `PendingSuggestionApplierRegistry`. Skipped for richtext — the
117
+ // Tiptap bridge registers its own editor-command applier. Skipped for
118
+ // dotted-path fields (Repeater inner rows) since `useFieldState` is
119
+ // a no-op for them; pill-driven approve falls back to `dismiss` which
120
+ // is the right semantics (pill cannot reach into row state).
121
+ const fieldState = useFieldState(name)
122
+ const fieldStateRef = useRef(fieldState)
123
+ useEffect(() => { fieldStateRef.current = fieldState }, [fieldState])
124
+ const formId = useContext(FormIdContext) || undefined
125
+ useEffect(() => {
126
+ if (isRichText) return
127
+ if (ownsApplier) return
128
+ if (name.includes('.')) return
129
+ const applier: PendingSuggestionApplier = (suggestion) => {
130
+ const fs = fieldStateRef.current
131
+ if (fs.controlled) {
132
+ fs.setValue(suggestion.suggestedValue)
133
+ } else {
134
+ applyToUncontrolledInputs(name, suggestion.suggestedValue)
135
+ }
136
+ }
137
+ return registerPendingSuggestionApplier(formId, name, applier)
138
+ }, [isRichText, ownsApplier, name, formId])
139
+
44
140
  const labelClass = hiddenLabel
45
141
  ? 'sr-only'
46
142
  : 'text-sm font-medium leading-none'
@@ -64,12 +160,29 @@ export function FieldShell({ el, name, label, required, children, before, after,
64
160
  )
65
161
  : children
66
162
 
163
+ // When a suggestion is pending we hide the real input visually but
164
+ // keep it in the DOM — both so the applier's DOM-write fallback can
165
+ // resolve `[name="…"]` and so the input doesn't unmount + lose its
166
+ // typed value. The wrapper div is rendered unconditionally (just
167
+ // toggling a class) — switching between bare input and wrapped input
168
+ // would unmount the uncontrolled `<input>`, resetting its value to
169
+ // `defaultValue` and silently undoing the approved write right after
170
+ // the overlay closes.
171
+ const inputBlock = (
172
+ <>
173
+ <div className={overlayNode !== null ? 'hidden' : 'contents'} aria-hidden={overlayNode !== null}>
174
+ {input}
175
+ </div>
176
+ {overlayNode}
177
+ </>
178
+ )
179
+
67
180
  if (inline) {
68
181
  return (
69
182
  <div className="flex items-baseline gap-3" {...wrapperAttrs}>
70
183
  {labelEl && <div className="min-w-32 pt-2">{labelEl}</div>}
71
184
  <div className="min-w-0 flex-1">
72
- {input}
185
+ {inputBlock}
73
186
  {helperText && (
74
187
  <p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
75
188
  )}
@@ -81,7 +194,7 @@ export function FieldShell({ el, name, label, required, children, before, after,
81
194
  return (
82
195
  <div className="flex flex-col gap-1.5" {...wrapperAttrs}>
83
196
  {labelEl}
84
- {input}
197
+ {inputBlock}
85
198
  {helperText && (
86
199
  <p className="text-xs text-muted-foreground">{helperText}</p>
87
200
  )}
@@ -89,6 +202,57 @@ export function FieldShell({ el, name, label, required, children, before, after,
89
202
  )
90
203
  }
91
204
 
205
+ /**
206
+ * Best-effort DOM apply for forms without a `<FormStateProvider>` (i.e.
207
+ * forms whose fields aren't `live()`). Walks every `[name="…"]` input,
208
+ * uses React's internal value-setter (`Object.getOwnPropertyDescriptor`)
209
+ * so the change is visible to `onChange` handlers + uncontrolled
210
+ * `defaultValue` paths. Coercion is intentionally minimal — `String()`
211
+ * for primitives, `JSON.stringify` for objects/arrays.
212
+ *
213
+ * Used by FieldShell's pending-suggestion applier and exposed for
214
+ * plugins that need the same fallback.
215
+ */
216
+ function applyToUncontrolledInputs(fieldName: string, value: unknown): void {
217
+ if (typeof document === 'undefined') return
218
+ const stringValue = typeof value === 'string'
219
+ ? value
220
+ : typeof value === 'number' || typeof value === 'boolean'
221
+ ? String(value)
222
+ : safeStringify(value)
223
+
224
+ const elements = document.getElementsByName(fieldName)
225
+ for (const el of Array.from(elements)) {
226
+ if (el instanceof HTMLInputElement) {
227
+ if (el.type === 'checkbox') {
228
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'checked')?.set
229
+ setter?.call(el, Boolean(value))
230
+ } else {
231
+ setNativeValue(HTMLInputElement.prototype, el, stringValue)
232
+ }
233
+ } else if (el instanceof HTMLTextAreaElement) {
234
+ setNativeValue(HTMLTextAreaElement.prototype, el, stringValue)
235
+ } else if (el instanceof HTMLSelectElement) {
236
+ setNativeValue(HTMLSelectElement.prototype, el, stringValue)
237
+ } else {
238
+ continue
239
+ }
240
+ el.dispatchEvent(new Event('input', { bubbles: true }))
241
+ el.dispatchEvent(new Event('change', { bubbles: true }))
242
+ }
243
+ }
244
+
245
+ function setNativeValue(proto: object, el: HTMLElement, value: string): void {
246
+ const desc = Object.getOwnPropertyDescriptor(proto, 'value')
247
+ desc?.set?.call(el, value)
248
+ }
249
+
250
+ function safeStringify(value: unknown): string {
251
+ if (value === null || value === undefined) return ''
252
+ try { return JSON.stringify(value) ?? '' }
253
+ catch { return '' }
254
+ }
255
+
92
256
  /**
93
257
  * Merge `extraAttributes` (Filament-parity short name) and
94
258
  * `extraFieldWrapperAttributes` (verbose alias) into one record. Latter
@@ -1,4 +1,4 @@
1
- import React, { useRef, useState } from 'react'
1
+ import React, { useContext, useEffect, useRef, useState } from 'react'
2
2
  import {
3
3
  UploadIcon, XIcon, FileIcon, Loader2Icon,
4
4
  GripVerticalIcon, DownloadIcon,
@@ -7,7 +7,8 @@ import ReactCrop, {
7
7
  type Crop, type PixelCrop,
8
8
  centerCrop, makeAspectCrop, convertToPixelCrop,
9
9
  } from 'react-image-crop'
10
- import { useFieldState } from '../FormStateContext.js'
10
+ import { useFieldState, FormIdContext } from '../FormStateContext.js'
11
+ import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
11
12
  import { useToast } from '../Toaster.js'
12
13
  import { Button } from '../ui/button.js'
13
14
  import {
@@ -104,6 +105,24 @@ export function FileUploadInput({
104
105
  }
105
106
  }
106
107
 
108
+ // Cross-tree applier — FileUpload state lives in `urls` (React); the
109
+ // hidden mirror input is write-only. FieldShell skips its generic
110
+ // registration for fieldType === 'fileUpload'.
111
+ const fsRef = useRef(fs)
112
+ useEffect(() => { fsRef.current = fs }, [fs])
113
+ const formId = useContext(FormIdContext) || undefined
114
+ useEffect(() => {
115
+ if (name.includes('.')) return
116
+ const applier: PendingSuggestionApplier = (suggestion) => {
117
+ const next = toUrls(suggestion.suggestedValue)
118
+ const stored = multiple ? next : (next[0] ?? null)
119
+ const cur = fsRef.current
120
+ if (cur.controlled) { cur.setValue(stored); cur.triggerLive(stored) }
121
+ else { setLocalUrls(next); cur.triggerLive(stored) }
122
+ }
123
+ return registerPendingSuggestionApplier(formId, name, applier)
124
+ }, [name, formId, multiple])
125
+
107
126
  // ── Image editor helpers ──────────────────────────────────────────────────
108
127
 
109
128
  const onImgLoad = (e: React.SyntheticEvent<HTMLImageElement>): void => {
@@ -1,6 +1,7 @@
1
- import React, { useMemo, useState } from 'react'
1
+ import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { Trash2Icon, PlusIcon, ArrowUpIcon, ArrowDownIcon } from 'lucide-react'
3
- import { useFieldState } from '../FormStateContext.js'
3
+ import { useFieldState, FormIdContext } from '../FormStateContext.js'
4
+ import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
4
5
  import { Input } from '../ui/input.js'
5
6
  import { Button } from '../ui/button.js'
6
7
 
@@ -70,6 +71,35 @@ export function KeyValueInput({
70
71
  const addRow = (): void => {
71
72
  setRows([...rows, { id: newId(), key: '', value: '' }])
72
73
  }
74
+ // Cross-tree applier — KeyValue rows are React-controlled; the JSON in
75
+ // the hidden input below is a write-only serialization. FieldShell skips
76
+ // its generic registration for fieldType === 'keyValue'.
77
+ const fsRef = useRef(fs)
78
+ useEffect(() => { fsRef.current = fs }, [fs])
79
+ const localRowsRef = useRef(localRows)
80
+ useEffect(() => { localRowsRef.current = localRows }, [localRows])
81
+ const formId = useContext(FormIdContext) || undefined
82
+ useEffect(() => {
83
+ if (name.includes('.')) return
84
+ const applier: PendingSuggestionApplier = (suggestion) => {
85
+ const obj = parseToObject(suggestion.suggestedValue)
86
+ const entries = Object.entries(obj)
87
+ const fallback = localRowsRef.current
88
+ const nextRows: Row[] = entries.length > 0
89
+ ? entries.map(([k, v], i) => ({ id: fallback[i]?.id ?? newId(), key: k, value: v }))
90
+ : [{ id: newId(), key: '', value: '' }]
91
+ const cur = fsRef.current
92
+ if (cur.controlled) {
93
+ cur.setValue(obj)
94
+ setLocalRows(nextRows)
95
+ } else {
96
+ setLocalRows(nextRows)
97
+ }
98
+ cur.triggerLive(obj)
99
+ }
100
+ return registerPendingSuggestionApplier(formId, name, applier)
101
+ }, [name, formId])
102
+
73
103
  const moveRow = (id: number, dir: -1 | 1): void => {
74
104
  const idx = rows.findIndex(r => r.id === id)
75
105
  if (idx < 0) return
@@ -1,5 +1,6 @@
1
- import React, { useState } from 'react'
2
- import { useFieldState } from '../FormStateContext.js'
1
+ import React, { useContext, useEffect, useRef, useState } from 'react'
2
+ import { useFieldState, FormIdContext } from '../FormStateContext.js'
3
+ import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
3
4
 
4
5
  /**
5
6
  * Single-choice field rendered as a vertical (or `inline:true` horizontal)
@@ -24,6 +25,26 @@ export function RadioInput({
24
25
  if (fs.controlled) { fs.setValue(next); fs.triggerLive(next) }
25
26
  else { setLocalValue(next); fs.triggerLive(next) }
26
27
  }
28
+
29
+ // Cross-tree applier — the visible radios live under a
30
+ // `${name}__radio`-named group (separate from the `[name]` hidden
31
+ // mirror), and they're React-controlled via `checked={value === o.value}`.
32
+ // FieldShell skips its generic registration for fieldType === 'radio'.
33
+ const fsRef = useRef(fs)
34
+ useEffect(() => { fsRef.current = fs }, [fs])
35
+ const formId = useContext(FormIdContext) || undefined
36
+ useEffect(() => {
37
+ if (name.includes('.')) return
38
+ const applier: PendingSuggestionApplier = (suggestion) => {
39
+ const v = suggestion.suggestedValue
40
+ const next = v == null ? '' : String(v)
41
+ const cur = fsRef.current
42
+ if (cur.controlled) { cur.setValue(next); cur.triggerLive(next) }
43
+ else { setLocalValue(next); cur.triggerLive(next) }
44
+ }
45
+ return registerPendingSuggestionApplier(formId, name, applier)
46
+ }, [name, formId])
47
+
27
48
  const layout = inline ? 'flex flex-row flex-wrap gap-4' : 'flex flex-col gap-2'
28
49
  return (
29
50
  <div role="radiogroup" className={layout}>
@@ -1,7 +1,8 @@
1
- import React, { useState } from 'react'
1
+ import React, { useContext, useEffect, useRef, useState } from 'react'
2
2
  import { PlusIcon } from 'lucide-react'
3
3
  import type { ElementMeta } from '../../schema/Element.js'
4
- import { useFieldState } from '../FormStateContext.js'
4
+ import { useFieldState, FormIdContext } from '../FormStateContext.js'
5
+ import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
5
6
  import { useToast } from '../Toaster.js'
6
7
  import { renderFormChild } from '../SchemaRenderer.js'
7
8
  import {
@@ -74,6 +75,28 @@ export function SelectFieldInput({
74
75
  else { setLocalValue(option.value); fs.triggerLive(option.value) }
75
76
  }
76
77
 
78
+ // Cross-tree applier registration. FieldShell's generic applier writes
79
+ // to the matching `[name]` input via the React prototype-descriptor
80
+ // setter — but Base UI Select isn't driven by the hidden `<input>` in
81
+ // this component, it's driven by `value`/`localValue` React state. So
82
+ // a DOM write moves the hidden input but leaves the visible select
83
+ // unchanged. Register a Select-aware applier that writes to local
84
+ // state instead. FieldShell skips the generic registration for
85
+ // fieldType === 'select' so this one stays the winner.
86
+ const fsRef = useRef(fs)
87
+ useEffect(() => { fsRef.current = fs }, [fs])
88
+ const formId = useContext(FormIdContext) || undefined
89
+ useEffect(() => {
90
+ if (name.includes('.')) return
91
+ const applier: PendingSuggestionApplier = (suggestion) => {
92
+ const next = suggestion.suggestedValue == null ? '' : String(suggestion.suggestedValue)
93
+ const cur = fsRef.current
94
+ if (cur.controlled) { cur.setValue(next); cur.triggerLive(next) }
95
+ else { setLocalValue(next); cur.triggerLive(next) }
96
+ }
97
+ return registerPendingSuggestionApplier(formId, name, applier)
98
+ }, [name, formId])
99
+
77
100
  const showCreateTrigger = createOption !== undefined
78
101
  && typeof createOption.url === 'string'
79
102
  && createOption.url.length > 0
@@ -1,5 +1,6 @@
1
- import React, { useState } from 'react'
2
- import { useFieldState } from '../FormStateContext.js'
1
+ import React, { useContext, useEffect, useRef, useState } from 'react'
2
+ import { useFieldState, FormIdContext } from '../FormStateContext.js'
3
+ import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
3
4
  import { Slider } from '../ui/slider.js'
4
5
 
5
6
  /**
@@ -40,6 +41,23 @@ export function SliderInput({
40
41
  else { setLocalValue(v); fs.triggerLive(v) }
41
42
  }
42
43
 
44
+ // Cross-tree applier — Base UI Slider drives via the `value` prop, not
45
+ // the hidden mirror input below. FieldShell skips its generic registration
46
+ // for fieldType === 'slider'.
47
+ const fsRef = useRef(fs)
48
+ useEffect(() => { fsRef.current = fs }, [fs])
49
+ const formId = useContext(FormIdContext) || undefined
50
+ useEffect(() => {
51
+ if (name.includes('.')) return
52
+ const applier: PendingSuggestionApplier = (suggestion) => {
53
+ const v = toNumber(suggestion.suggestedValue)
54
+ const cur = fsRef.current
55
+ if (cur.controlled) { cur.setValue(v); cur.triggerLive(v) }
56
+ else { setLocalValue(v); cur.triggerLive(v) }
57
+ }
58
+ return registerPendingSuggestionApplier(formId, name, applier)
59
+ }, [name, formId])
60
+
43
61
  return (
44
62
  <div className="flex items-center gap-3">
45
63
  <input type="hidden" name={name} value={String(value)} />
@@ -1,6 +1,7 @@
1
- import React, { useMemo, useRef, useState } from 'react'
1
+ import React, { useContext, useEffect, useMemo, useRef, useState } from 'react'
2
2
  import { GripVerticalIcon, XIcon } from 'lucide-react'
3
- import { useFieldState } from '../FormStateContext.js'
3
+ import { useFieldState, FormIdContext } from '../FormStateContext.js'
4
+ import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
4
5
  import { reorderRows } from './RepeaterInput.js'
5
6
 
6
7
  /**
@@ -48,6 +49,23 @@ export function TagsInput({
48
49
  else { setLocalTags(next); fs.triggerLive(next) }
49
50
  }
50
51
 
52
+ // Cross-tree applier — chip set lives in React; hidden mirror is a
53
+ // write-only JSON serialization. FieldShell skips its generic
54
+ // registration for fieldType === 'tagsInput'.
55
+ const fsRef = useRef(fs)
56
+ useEffect(() => { fsRef.current = fs }, [fs])
57
+ const formId = useContext(FormIdContext) || undefined
58
+ useEffect(() => {
59
+ if (name.includes('.')) return
60
+ const applier: PendingSuggestionApplier = (suggestion) => {
61
+ const next = toArray(suggestion.suggestedValue)
62
+ const cur = fsRef.current
63
+ if (cur.controlled) { cur.setValue(next); cur.triggerLive(next) }
64
+ else { setLocalTags(next); cur.triggerLive(next) }
65
+ }
66
+ return registerPendingSuggestionApplier(formId, name, applier)
67
+ }, [name, formId])
68
+
51
69
  const canAddMore = maxTags == null || tags.length < maxTags
52
70
 
53
71
  const addTag = (raw: string): void => {
@@ -1,5 +1,6 @@
1
- import React, { useState } from 'react'
2
- import { useFieldState } from '../FormStateContext.js'
1
+ import React, { useContext, useEffect, useRef, useState } from 'react'
2
+ import { useFieldState, FormIdContext } from '../FormStateContext.js'
3
+ import { registerPendingSuggestionApplier, type PendingSuggestionApplier } from '../PendingSuggestionApplierRegistry.js'
3
4
  import { Switch } from '../ui/switch.js'
4
5
 
5
6
  export function ToggleFieldInput({
@@ -21,6 +22,26 @@ export function ToggleFieldInput({
21
22
  if (fs.controlled) { fs.setValue(next); fs.triggerLive(next) }
22
23
  else { setLocalChecked(next); fs.triggerLive(next) }
23
24
  }
25
+
26
+ // Cross-tree applier — Switch state lives in React, not in the hidden
27
+ // mirror input below. FieldShell's generic DOM-write applier would
28
+ // dispatch a change on the hidden input, but the visible Switch has
29
+ // no listener for it, so the toggle wouldn't flip. FieldShell skips
30
+ // its generic registration for fieldType === 'toggle'.
31
+ const fsRef = useRef(fs)
32
+ useEffect(() => { fsRef.current = fs }, [fs])
33
+ const formId = useContext(FormIdContext) || undefined
34
+ useEffect(() => {
35
+ if (name.includes('.')) return
36
+ const applier: PendingSuggestionApplier = (suggestion) => {
37
+ const v = suggestion.suggestedValue
38
+ const next = v === true || v === 'true' || v === 1 || v === '1'
39
+ const cur = fsRef.current
40
+ if (cur.controlled) { cur.setValue(next); cur.triggerLive(next) }
41
+ else { setLocalChecked(next); cur.triggerLive(next) }
42
+ }
43
+ return registerPendingSuggestionApplier(formId, name, applier)
44
+ }, [name, formId])
24
45
  return (
25
46
  <div className="flex items-center gap-2">
26
47
  <input type="hidden" name={name} value={checked ? 'true' : 'false'} />
@@ -14,6 +14,24 @@ export {
14
14
  } from './SchemaRenderer.js'
15
15
  export { registerFieldRenderer, getFieldRenderer, type FieldRendererProps } from './registry.js'
16
16
  export { registerFieldLabelSlot, getFieldLabelSlot, type FieldLabelSlotProps } from './FieldLabelSlotRegistry.js'
17
+ export {
18
+ registerPendingSuggestionOverlay,
19
+ getPendingSuggestionOverlay,
20
+ type PendingSuggestionOverlayProps,
21
+ } from './PendingSuggestionOverlayRegistry.js'
22
+ export {
23
+ PendingSuggestionsContext,
24
+ usePendingSuggestions,
25
+ usePendingSuggestionsForField,
26
+ type PendingSuggestion,
27
+ type PendingSuggestionOrigin,
28
+ type PendingSuggestionsApi,
29
+ } from './PendingSuggestionsContext.js'
30
+ export {
31
+ registerPendingSuggestionApplier,
32
+ getPendingSuggestionApplier,
33
+ type PendingSuggestionApplier,
34
+ } from './PendingSuggestionApplierRegistry.js'
17
35
  export {
18
36
  registerWidgetRenderer,
19
37
  getWidgetRenderer,