@dxos/react-ui-form 0.8.4-main.ead640a → 0.8.4-main.effb148878

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 (268) hide show
  1. package/LICENSE +102 -5
  2. package/README.md +0 -1
  3. package/dist/lib/browser/index.mjs +3252 -2315
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/translations.mjs +83 -0
  7. package/dist/lib/browser/translations.mjs.map +7 -0
  8. package/dist/lib/node-esm/index.mjs +3252 -2315
  9. package/dist/lib/node-esm/index.mjs.map +4 -4
  10. package/dist/lib/node-esm/meta.json +1 -1
  11. package/dist/lib/node-esm/translations.mjs +85 -0
  12. package/dist/lib/node-esm/translations.mjs.map +7 -0
  13. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +8 -7
  14. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  15. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +69 -60
  16. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/Form.d.ts +123 -26
  18. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/Form.stories.d.ts +175 -138
  20. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/FormField.d.ts +48 -0
  22. package/dist/types/src/components/Form/FormField.d.ts.map +1 -0
  23. package/dist/types/src/components/Form/FormFieldComponent.d.ts +95 -0
  24. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -0
  25. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts +9 -0
  26. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -0
  27. package/dist/types/src/components/Form/FormFieldSet.d.ts +21 -0
  28. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -0
  29. package/dist/types/src/components/Form/FormTooltipsContext.d.ts +19 -0
  30. package/dist/types/src/components/Form/FormTooltipsContext.d.ts.map +1 -0
  31. package/dist/types/src/components/Form/Layout/FormLayout.d.ts +25 -0
  32. package/dist/types/src/components/Form/Layout/FormLayout.d.ts.map +1 -0
  33. package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts +116 -0
  34. package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts.map +1 -0
  35. package/dist/types/src/components/Form/Layout/annotation.d.ts +30 -0
  36. package/dist/types/src/components/Form/Layout/annotation.d.ts.map +1 -0
  37. package/dist/types/src/components/Form/Layout/index.d.ts +4 -0
  38. package/dist/types/src/components/Form/Layout/index.d.ts.map +1 -0
  39. package/dist/types/src/components/Form/Layout/parser.d.ts +31 -0
  40. package/dist/types/src/components/Form/Layout/parser.d.ts.map +1 -0
  41. package/dist/types/src/components/Form/Layout/parser.test.d.ts +2 -0
  42. package/dist/types/src/components/Form/Layout/parser.test.d.ts.map +1 -0
  43. package/dist/types/src/components/Form/fields/ArrayField.d.ts +18 -0
  44. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -0
  45. package/dist/types/src/components/Form/fields/BooleanField.d.ts +4 -0
  46. package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -0
  47. package/dist/types/src/components/Form/fields/DateField.d.ts +4 -0
  48. package/dist/types/src/components/Form/fields/DateField.d.ts.map +1 -0
  49. package/dist/types/src/components/Form/fields/GeoPointField.d.ts +5 -0
  50. package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -0
  51. package/dist/types/src/components/Form/fields/MarkdownField.d.ts +11 -0
  52. package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -0
  53. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +85 -0
  54. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -0
  55. package/dist/types/src/components/Form/fields/NumberField.d.ts +4 -0
  56. package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -0
  57. package/dist/types/src/components/Form/fields/RefField.d.ts +22 -0
  58. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -0
  59. package/dist/types/src/components/Form/fields/SelectField.d.ts +13 -0
  60. package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -0
  61. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts +5 -0
  62. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -0
  63. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +85 -0
  64. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -0
  65. package/dist/types/src/components/Form/fields/TextAreaField.d.ts +4 -0
  66. package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -0
  67. package/dist/types/src/components/Form/fields/TextField.d.ts +4 -0
  68. package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -0
  69. package/dist/types/src/components/Form/fields/TupleField.d.ts +6 -0
  70. package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -0
  71. package/dist/types/src/components/Form/fields/index.d.ts +13 -0
  72. package/dist/types/src/components/Form/fields/index.d.ts.map +1 -0
  73. package/dist/types/src/components/Form/index.d.ts +4 -4
  74. package/dist/types/src/components/Form/index.d.ts.map +1 -1
  75. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +8 -0
  76. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -0
  77. package/dist/types/src/components/ObjectForm/index.d.ts +2 -0
  78. package/dist/types/src/components/ObjectForm/index.d.ts.map +1 -0
  79. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +37 -10
  80. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  81. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +65 -59
  82. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  83. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +9 -0
  84. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -0
  85. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +128 -0
  86. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -0
  87. package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts +2 -0
  88. package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts.map +1 -0
  89. package/dist/types/src/components/ObjectProperties/index.d.ts +2 -0
  90. package/dist/types/src/components/ObjectProperties/index.d.ts.map +1 -0
  91. package/dist/types/src/components/ObjectTree/ObjectTree.d.ts +9 -0
  92. package/dist/types/src/components/ObjectTree/ObjectTree.d.ts.map +1 -0
  93. package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts +87 -0
  94. package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts.map +1 -0
  95. package/dist/types/src/components/ObjectTree/index.d.ts +2 -0
  96. package/dist/types/src/components/ObjectTree/index.d.ts.map +1 -0
  97. package/dist/types/src/components/Settings/Settings.d.ts +51 -0
  98. package/dist/types/src/components/Settings/Settings.d.ts.map +1 -0
  99. package/dist/types/src/components/Settings/Settings.stories.d.ts +85 -0
  100. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -0
  101. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +43 -0
  102. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +1 -0
  103. package/dist/types/src/components/Settings/SettingsItem.d.ts +16 -0
  104. package/dist/types/src/components/Settings/SettingsItem.d.ts.map +1 -0
  105. package/dist/types/src/components/Settings/SettingsSelectField.d.ts +18 -0
  106. package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +1 -0
  107. package/dist/types/src/components/Settings/index.d.ts +4 -0
  108. package/dist/types/src/components/Settings/index.d.ts.map +1 -0
  109. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +16 -20
  110. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  111. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +72 -66
  112. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  113. package/dist/types/src/components/index.d.ts +4 -1
  114. package/dist/types/src/components/index.d.ts.map +1 -1
  115. package/dist/types/src/components/testing.d.ts +13 -6
  116. package/dist/types/src/components/testing.d.ts.map +1 -1
  117. package/dist/types/src/hooks/index.d.ts +2 -3
  118. package/dist/types/src/hooks/index.d.ts.map +1 -1
  119. package/dist/types/src/hooks/useFormHandler.d.ts +82 -0
  120. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -0
  121. package/dist/types/src/hooks/useKeyHandler.d.ts +7 -0
  122. package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -0
  123. package/dist/types/src/index.d.ts +0 -2
  124. package/dist/types/src/index.d.ts.map +1 -1
  125. package/dist/types/src/translations.d.ts +66 -60
  126. package/dist/types/src/translations.d.ts.map +1 -1
  127. package/dist/types/src/util/field-type.d.ts +19 -0
  128. package/dist/types/src/util/field-type.d.ts.map +1 -0
  129. package/dist/types/src/util/field.d.ts +3 -3
  130. package/dist/types/src/util/field.d.ts.map +1 -1
  131. package/dist/types/src/util/formatting.d.ts +2 -2
  132. package/dist/types/src/util/formatting.d.ts.map +1 -1
  133. package/dist/types/src/util/index.d.ts +2 -1
  134. package/dist/types/src/util/index.d.ts.map +1 -1
  135. package/dist/types/src/util/properties.d.ts +17 -0
  136. package/dist/types/src/util/properties.d.ts.map +1 -0
  137. package/dist/types/src/util/properties.test.d.ts +2 -0
  138. package/dist/types/src/util/properties.test.d.ts.map +1 -0
  139. package/dist/types/src/util/refs.d.ts +2 -2
  140. package/dist/types/src/util/refs.d.ts.map +1 -1
  141. package/dist/types/tsconfig.tsbuildinfo +1 -1
  142. package/package.json +61 -51
  143. package/src/components/FieldEditor/FieldEditor.stories.tsx +44 -25
  144. package/src/components/FieldEditor/FieldEditor.test.tsx +0 -1
  145. package/src/components/FieldEditor/FieldEditor.tsx +84 -84
  146. package/src/components/Form/AUDIT.md +17 -0
  147. package/src/components/Form/Form.stories.tsx +134 -252
  148. package/src/components/Form/Form.tsx +417 -107
  149. package/src/components/Form/FormField.tsx +332 -0
  150. package/src/components/Form/FormFieldComponent.stories.tsx +65 -0
  151. package/src/components/Form/FormFieldComponent.tsx +206 -0
  152. package/src/components/Form/FormFieldSet.tsx +173 -0
  153. package/src/components/Form/FormTooltipsContext.ts +25 -0
  154. package/src/components/Form/Layout/FormLayout.stories.tsx +339 -0
  155. package/src/components/Form/Layout/FormLayout.tsx +123 -0
  156. package/src/components/Form/Layout/annotation.ts +38 -0
  157. package/src/components/Form/Layout/index.ts +7 -0
  158. package/src/components/Form/Layout/parser.test.ts +120 -0
  159. package/src/components/Form/Layout/parser.ts +167 -0
  160. package/src/components/Form/README.md +3 -0
  161. package/src/components/Form/fields/ArrayField.tsx +185 -0
  162. package/src/components/Form/fields/BooleanField.tsx +22 -0
  163. package/src/components/Form/fields/DateField.tsx +95 -0
  164. package/src/components/Form/fields/GeoPointField.tsx +124 -0
  165. package/src/components/Form/fields/MarkdownField.stories.tsx +206 -0
  166. package/src/components/Form/fields/MarkdownField.tsx +168 -0
  167. package/src/components/Form/fields/NumberField.tsx +39 -0
  168. package/src/components/Form/fields/RefField.tsx +232 -0
  169. package/src/components/Form/fields/SelectField.tsx +77 -0
  170. package/src/components/Form/{custom/SelectOptionsInput.stories.tsx → fields/SelectOptionField.stories.tsx} +23 -18
  171. package/src/components/Form/{custom/SelectOptionsInput.tsx → fields/SelectOptionField.tsx} +31 -24
  172. package/src/components/Form/fields/TextAreaField.tsx +38 -0
  173. package/src/components/Form/fields/TextField.tsx +38 -0
  174. package/src/components/Form/{custom/TupleInput.tsx → fields/TupleField.tsx} +17 -9
  175. package/src/components/Form/fields/index.ts +16 -0
  176. package/src/components/Form/index.ts +5 -4
  177. package/src/components/ObjectForm/ObjectForm.tsx +115 -0
  178. package/src/components/{ControlSection → ObjectForm}/index.ts +1 -1
  179. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +30 -25
  180. package/src/components/ObjectPicker/ObjectPicker.tsx +122 -81
  181. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +350 -0
  182. package/src/components/ObjectProperties/ObjectProperties.test.tsx +108 -0
  183. package/src/components/ObjectProperties/ObjectProperties.tsx +147 -0
  184. package/src/components/ObjectProperties/index.ts +5 -0
  185. package/src/components/ObjectTree/ObjectTree.stories.tsx +134 -0
  186. package/src/components/ObjectTree/ObjectTree.tsx +190 -0
  187. package/src/components/ObjectTree/index.ts +5 -0
  188. package/src/components/Settings/AUDIT.md +51 -0
  189. package/src/components/Settings/Settings.stories.tsx +104 -0
  190. package/src/components/Settings/Settings.tsx +81 -0
  191. package/src/components/Settings/SettingsFieldSet.tsx +257 -0
  192. package/src/components/Settings/SettingsItem.tsx +58 -0
  193. package/src/components/Settings/SettingsSelectField.tsx +59 -0
  194. package/src/components/Settings/index.ts +7 -0
  195. package/src/components/ViewEditor/ViewEditor.stories.tsx +65 -73
  196. package/src/components/ViewEditor/ViewEditor.test.tsx +11 -11
  197. package/src/components/ViewEditor/ViewEditor.tsx +308 -214
  198. package/src/components/index.ts +4 -1
  199. package/src/components/testing.tsx +24 -11
  200. package/src/hooks/index.ts +2 -3
  201. package/src/hooks/useFormHandler.ts +380 -0
  202. package/src/hooks/useKeyHandler.tsx +37 -0
  203. package/src/index.ts +0 -4
  204. package/src/translations.ts +71 -65
  205. package/src/util/field-type.ts +60 -0
  206. package/src/util/field.ts +25 -25
  207. package/src/util/formatting.ts +22 -22
  208. package/src/util/index.ts +2 -1
  209. package/src/util/properties.test.ts +78 -0
  210. package/src/util/properties.ts +38 -0
  211. package/src/util/refs.ts +16 -18
  212. package/src/vite-env.d.ts +10 -0
  213. package/dist/types/src/components/ControlSection/ControlSection.d.ts +0 -23
  214. package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +0 -1
  215. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts +0 -84
  216. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +0 -1
  217. package/dist/types/src/components/ControlSection/index.d.ts +0 -2
  218. package/dist/types/src/components/ControlSection/index.d.ts.map +0 -1
  219. package/dist/types/src/components/Form/ArrayField.d.ts +0 -16
  220. package/dist/types/src/components/Form/ArrayField.d.ts.map +0 -1
  221. package/dist/types/src/components/Form/Defaults.d.ts +0 -14
  222. package/dist/types/src/components/Form/Defaults.d.ts.map +0 -1
  223. package/dist/types/src/components/Form/FormActions.d.ts +0 -9
  224. package/dist/types/src/components/Form/FormActions.d.ts.map +0 -1
  225. package/dist/types/src/components/Form/FormContent.d.ts +0 -63
  226. package/dist/types/src/components/Form/FormContent.d.ts.map +0 -1
  227. package/dist/types/src/components/Form/FormContext.d.ts +0 -23
  228. package/dist/types/src/components/Form/FormContext.d.ts.map +0 -1
  229. package/dist/types/src/components/Form/Input.d.ts +0 -29
  230. package/dist/types/src/components/Form/Input.d.ts.map +0 -1
  231. package/dist/types/src/components/Form/RefField.d.ts +0 -22
  232. package/dist/types/src/components/Form/RefField.d.ts.map +0 -1
  233. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +0 -4
  234. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +0 -1
  235. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +0 -4
  236. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +0 -1
  237. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +0 -79
  238. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +0 -1
  239. package/dist/types/src/components/Form/custom/TupleInput.d.ts +0 -6
  240. package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +0 -1
  241. package/dist/types/src/components/Form/custom/index.d.ts +0 -4
  242. package/dist/types/src/components/Form/custom/index.d.ts.map +0 -1
  243. package/dist/types/src/components/Form/factory.d.ts +0 -8
  244. package/dist/types/src/components/Form/factory.d.ts.map +0 -1
  245. package/dist/types/src/hooks/useForm.d.ts +0 -67
  246. package/dist/types/src/hooks/useForm.d.ts.map +0 -1
  247. package/dist/types/src/hooks/useQueryRefOptions.d.ts +0 -29
  248. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +0 -1
  249. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts +0 -8
  250. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +0 -1
  251. package/dist/types/src/util/schema.d.ts +0 -3
  252. package/dist/types/src/util/schema.d.ts.map +0 -1
  253. package/src/components/ControlSection/ControlSection.stories.tsx +0 -38
  254. package/src/components/ControlSection/ControlSection.tsx +0 -153
  255. package/src/components/Form/ArrayField.tsx +0 -125
  256. package/src/components/Form/Defaults.tsx +0 -205
  257. package/src/components/Form/FormActions.tsx +0 -54
  258. package/src/components/Form/FormContent.tsx +0 -322
  259. package/src/components/Form/FormContext.tsx +0 -110
  260. package/src/components/Form/Input.tsx +0 -54
  261. package/src/components/Form/RefField.tsx +0 -283
  262. package/src/components/Form/custom/GeoPointInput.tsx +0 -80
  263. package/src/components/Form/custom/index.ts +0 -7
  264. package/src/components/Form/factory.tsx +0 -31
  265. package/src/hooks/useForm.ts +0 -296
  266. package/src/hooks/useQueryRefOptions.ts +0 -62
  267. package/src/hooks/useRefQueryLookupHandler.ts +0 -39
  268. package/src/util/schema.ts +0 -12
@@ -0,0 +1,7 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ export * from './annotation';
6
+ export { FormLayout } from './FormLayout';
7
+ export { LayoutParseError, parseLayout, type LayoutNode } from './parser';
@@ -0,0 +1,120 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { LayoutParseError, parseLayout } from './parser';
8
+
9
+ describe('parseLayout', () => {
10
+ test('grid with two fields', ({ expect }) => {
11
+ const tree = parseLayout(`<grid cols="2"><field name="a"/><field name="b"/></grid>`);
12
+ expect(tree).toEqual({
13
+ kind: 'grid',
14
+ cols: 2,
15
+ children: [
16
+ { kind: 'field', name: 'a', span: undefined },
17
+ { kind: 'field', name: 'b', span: undefined },
18
+ ],
19
+ });
20
+ });
21
+
22
+ test('field with span attribute', ({ expect }) => {
23
+ const tree = parseLayout(`<grid cols="3"><field name="a" span="2"/><field name="b"/></grid>`);
24
+ expect(tree).toEqual({
25
+ kind: 'grid',
26
+ cols: 3,
27
+ children: [
28
+ { kind: 'field', name: 'a', span: 2 },
29
+ { kind: 'field', name: 'b', span: undefined },
30
+ ],
31
+ });
32
+ });
33
+
34
+ test('dotted field name', ({ expect }) => {
35
+ const tree = parseLayout(`<grid cols="1"><field name="details.airline"/></grid>`);
36
+ expect(tree).toEqual({
37
+ kind: 'grid',
38
+ cols: 1,
39
+ children: [{ kind: 'field', name: 'details.airline', span: undefined }],
40
+ });
41
+ });
42
+
43
+ test('nested grids', ({ expect }) => {
44
+ const tree = parseLayout(`
45
+ <grid cols="2">
46
+ <field name="a"/>
47
+ <grid cols="2">
48
+ <field name="b"/>
49
+ <field name="c"/>
50
+ </grid>
51
+ </grid>
52
+ `);
53
+ expect(tree.kind).toBe('grid');
54
+ if (tree.kind !== 'grid') {
55
+ return;
56
+ }
57
+ expect(tree.cols).toBe(2);
58
+ expect(tree.children).toHaveLength(2);
59
+ const inner = tree.children[1];
60
+ expect(inner.kind).toBe('grid');
61
+ if (inner.kind === 'grid') {
62
+ expect(inner.cols).toBe(2);
63
+ expect(inner.children).toHaveLength(2);
64
+ }
65
+ });
66
+
67
+ test('accepts single-quoted and unquoted attributes', ({ expect }) => {
68
+ const tree = parseLayout(`<grid cols=2><field name='x' span=3/></grid>`);
69
+ expect(tree).toEqual({
70
+ kind: 'grid',
71
+ cols: 2,
72
+ children: [{ kind: 'field', name: 'x', span: 3 }],
73
+ });
74
+ });
75
+
76
+ test('ignores whitespace between tags', ({ expect }) => {
77
+ const tree = parseLayout(`
78
+ <grid cols="1">
79
+ <field name="x"/>
80
+ </grid>
81
+ `);
82
+ expect(tree.kind).toBe('grid');
83
+ });
84
+
85
+ test('empty template throws', ({ expect }) => {
86
+ expect(() => parseLayout('')).toThrow(LayoutParseError);
87
+ });
88
+
89
+ test('text content between tags throws', ({ expect }) => {
90
+ expect(() => parseLayout(`<grid cols="1">hello<field name="x"/></grid>`)).toThrow(/unexpected text content/);
91
+ });
92
+
93
+ test('missing required cols attribute throws', ({ expect }) => {
94
+ expect(() => parseLayout(`<grid><field name="x"/></grid>`)).toThrow(/missing required attribute "cols"/);
95
+ });
96
+
97
+ test('missing required field name throws', ({ expect }) => {
98
+ expect(() => parseLayout(`<grid cols="1"><field/></grid>`)).toThrow(/missing required attribute "name"/);
99
+ });
100
+
101
+ test('non-integer cols throws', ({ expect }) => {
102
+ expect(() => parseLayout(`<grid cols="auto"><field name="x"/></grid>`)).toThrow(/expects an integer/);
103
+ });
104
+
105
+ test('non-grid root throws', ({ expect }) => {
106
+ expect(() => parseLayout(`<field name="x"/>`)).toThrow(/root element must be <grid>/);
107
+ });
108
+
109
+ test('unclosed grid throws', ({ expect }) => {
110
+ expect(() => parseLayout(`<grid cols="1"><field name="x"/>`)).toThrow(/<grid> not closed/);
111
+ });
112
+
113
+ test('non-self-closing field throws', ({ expect }) => {
114
+ expect(() => parseLayout(`<grid cols="1"><field name="x"></field></grid>`)).toThrow(/must be self-closing/);
115
+ });
116
+
117
+ test('unknown element throws', ({ expect }) => {
118
+ expect(() => parseLayout(`<grid cols="1"><row name="x"/></grid>`)).toThrow(/unknown element <row>/);
119
+ });
120
+ });
@@ -0,0 +1,167 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ /**
6
+ * Minimal XML-style DSL for laying out schema fields in a grid.
7
+ *
8
+ * Grammar:
9
+ * <grid cols="N"> ...children... </grid>
10
+ * <field name="x" span="N"/>
11
+ *
12
+ * - Attributes accept `"…"`, `'…'`, or unquoted integers.
13
+ * - Whitespace between tags is ignored. Text content (between tags) is forbidden.
14
+ * - Self-closing form (`<field … />`) is required for `field`; `grid` must use a closing tag.
15
+ *
16
+ * The parser is hand-rolled (no HTML parser) because the grammar is tiny.
17
+ */
18
+
19
+ /** Node type for the parsed layout tree. */
20
+ export type LayoutNode =
21
+ | { readonly kind: 'grid'; readonly cols: number; readonly children: readonly LayoutNode[] }
22
+ | { readonly kind: 'field'; readonly name: string; readonly span?: number };
23
+
24
+ export class LayoutParseError extends Error {
25
+ readonly _tag = 'LayoutParseError';
26
+ constructor(
27
+ message: string,
28
+ readonly position?: number,
29
+ ) {
30
+ super(position !== undefined ? `${message} (at position ${position})` : message);
31
+ }
32
+ }
33
+
34
+ const TAG_RE = /<\s*(\/?)\s*(\w+)([^>]*?)(\/?)\s*>/g;
35
+ const ATTR_RE = /([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|(\d+))/g;
36
+
37
+ type Token =
38
+ | { type: 'open'; name: string; attrs: Record<string, string>; selfClosing: boolean; position: number }
39
+ | { type: 'close'; name: string; position: number };
40
+
41
+ const parseAttrs = (raw: string): Record<string, string> => {
42
+ const attrs: Record<string, string> = {};
43
+ ATTR_RE.lastIndex = 0;
44
+ let match: RegExpExecArray | null;
45
+ while ((match = ATTR_RE.exec(raw)) !== null) {
46
+ const [, key, doubleQuoted, singleQuoted, bare] = match;
47
+ attrs[key] = doubleQuoted ?? singleQuoted ?? bare;
48
+ }
49
+ return attrs;
50
+ };
51
+
52
+ const tokenize = (input: string): Token[] => {
53
+ const tokens: Token[] = [];
54
+ TAG_RE.lastIndex = 0;
55
+ let match: RegExpExecArray | null;
56
+ let cursor = 0;
57
+ while ((match = TAG_RE.exec(input)) !== null) {
58
+ // Validate that any text between the previous tag and this one is whitespace-only.
59
+ const between = input.slice(cursor, match.index);
60
+ if (between.trim().length > 0) {
61
+ throw new LayoutParseError(`unexpected text content: ${JSON.stringify(between.trim())}`, cursor);
62
+ }
63
+ const [, slash, name, attrsRaw, selfSlash] = match;
64
+ if (slash) {
65
+ tokens.push({ type: 'close', name, position: match.index });
66
+ } else {
67
+ tokens.push({
68
+ type: 'open',
69
+ name,
70
+ attrs: parseAttrs(attrsRaw),
71
+ selfClosing: selfSlash === '/',
72
+ position: match.index,
73
+ });
74
+ }
75
+ cursor = match.index + match[0].length;
76
+ }
77
+ // Any trailing text after the last tag must also be whitespace.
78
+ const trailing = input.slice(cursor);
79
+ if (trailing.trim().length > 0) {
80
+ throw new LayoutParseError(`unexpected text content: ${JSON.stringify(trailing.trim())}`, cursor);
81
+ }
82
+ return tokens;
83
+ };
84
+
85
+ const requireAttr = (attrs: Record<string, string>, key: string, tag: string, position: number): string => {
86
+ const value = attrs[key];
87
+ if (value === undefined) {
88
+ throw new LayoutParseError(`<${tag}> missing required attribute "${key}"`, position);
89
+ }
90
+ return value;
91
+ };
92
+
93
+ const parseInteger = (value: string, attr: string, tag: string, position: number): number => {
94
+ if (!/^\d+$/.test(value)) {
95
+ throw new LayoutParseError(
96
+ `<${tag}> attribute "${attr}" expects an integer, got ${JSON.stringify(value)}`,
97
+ position,
98
+ );
99
+ }
100
+ return Number.parseInt(value, 10);
101
+ };
102
+
103
+ /** Parses a layout template string into a single root node. The root must be `<grid>`. */
104
+ export const parseLayout = (template: string): LayoutNode => {
105
+ const tokens = tokenize(template);
106
+ if (tokens.length === 0) {
107
+ throw new LayoutParseError('empty template');
108
+ }
109
+
110
+ let index = 0;
111
+
112
+ const parseNode = (): LayoutNode => {
113
+ const token = tokens[index];
114
+ if (!token || token.type !== 'open') {
115
+ throw new LayoutParseError(`expected an opening tag, got ${JSON.stringify(token)}`, token?.position);
116
+ }
117
+
118
+ if (token.name === 'field') {
119
+ if (!token.selfClosing) {
120
+ throw new LayoutParseError('<field> must be self-closing (e.g. <field name="x"/>)', token.position);
121
+ }
122
+ const name = requireAttr(token.attrs, 'name', 'field', token.position);
123
+ const spanRaw = token.attrs.span;
124
+ const span = spanRaw !== undefined ? parseInteger(spanRaw, 'span', 'field', token.position) : undefined;
125
+ index += 1;
126
+ return { kind: 'field', name, span };
127
+ }
128
+
129
+ if (token.name === 'grid') {
130
+ if (token.selfClosing) {
131
+ throw new LayoutParseError('<grid> must wrap children (cannot self-close)', token.position);
132
+ }
133
+ const cols = parseInteger(
134
+ requireAttr(token.attrs, 'cols', 'grid', token.position),
135
+ 'cols',
136
+ 'grid',
137
+ token.position,
138
+ );
139
+ index += 1;
140
+ const children: LayoutNode[] = [];
141
+ while (index < tokens.length && !(tokens[index].type === 'close' && tokens[index].name === 'grid')) {
142
+ children.push(parseNode());
143
+ }
144
+ const closing = tokens[index];
145
+ if (!closing || closing.type !== 'close' || closing.name !== 'grid') {
146
+ throw new LayoutParseError('<grid> not closed', token.position);
147
+ }
148
+ index += 1;
149
+ return { kind: 'grid', cols, children };
150
+ }
151
+
152
+ throw new LayoutParseError(`unknown element <${token.name}>`, token.position);
153
+ };
154
+
155
+ const root = parseNode();
156
+ if (index !== tokens.length) {
157
+ const trailing = tokens[index];
158
+ throw new LayoutParseError(
159
+ `unexpected trailing tag <${trailing.type === 'open' ? '' : '/'}${trailing.name}>`,
160
+ trailing.position,
161
+ );
162
+ }
163
+ if (root.kind !== 'grid') {
164
+ throw new LayoutParseError('root element must be <grid>');
165
+ }
166
+ return root;
167
+ };
@@ -0,0 +1,3 @@
1
+ # Form
2
+
3
+ - [ ] Layout options (e.g., label on the side.)
@@ -0,0 +1,185 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Option from 'effect/Option';
6
+ import * as SchemaAST from 'effect/SchemaAST';
7
+ import React, { useCallback } from 'react';
8
+
9
+ import { Ref } from '@dxos/echo';
10
+ import {
11
+ createJsonPath,
12
+ findNode,
13
+ getArrayElementType,
14
+ getDiscriminatedType,
15
+ isDiscriminatedUnion,
16
+ isNestedType,
17
+ } from '@dxos/effect';
18
+ import { IconButton, IconButtonProps, useTranslation } from '@dxos/react-ui';
19
+
20
+ import { translationKey } from '#translations';
21
+
22
+ import { getFormProperties } from '../../../util';
23
+ import { useFormValues } from '../Form';
24
+ import { FormField, type FormFieldProps } from '../FormField';
25
+ import { FormFieldLabel, type FormFieldStateProps } from '../FormFieldComponent';
26
+
27
+ export type ArrayFieldProps = {
28
+ label: string;
29
+ fieldProps: FormFieldStateProps;
30
+ } & FormFieldProps;
31
+
32
+ export const ArrayField = ({
33
+ type,
34
+ path,
35
+ label,
36
+ readonly,
37
+ layout,
38
+ fieldProps: inputProps,
39
+ ...props
40
+ }: ArrayFieldProps) => {
41
+ const { t } = useTranslation(translationKey);
42
+ const elementType = getArrayElementType(type);
43
+ const { onValueChange } = inputProps;
44
+ const values = useFormValues(ArrayField.displayName, path, () => []);
45
+
46
+ const getDefaultObjectValue = (typeNode: SchemaAST.AST): any => {
47
+ const baseNode = findNode(typeNode, isDiscriminatedUnion);
48
+ const typeLiteral = baseNode ? getDiscriminatedType(baseNode, {}) : findNode(typeNode, SchemaAST.isTypeLiteral);
49
+ if (!typeLiteral) {
50
+ return {};
51
+ }
52
+
53
+ return Object.fromEntries(
54
+ getFormProperties(typeLiteral).map((prop) => {
55
+ const defaultValue = SchemaAST.getDefaultAnnotation(prop.type).pipe((annotation) =>
56
+ Option.getOrUndefined(annotation),
57
+ );
58
+ return [prop.name, defaultValue];
59
+ }),
60
+ );
61
+ };
62
+
63
+ const handleAdd = useCallback(() => {
64
+ const defaultValue =
65
+ isNestedType(type) && elementType ? getDefaultObjectValue(elementType) : getDefaultValue(elementType);
66
+ // `values` is `undefined` on first render for arrays whose parent path
67
+ // hasn't been materialised in the form values yet (e.g. `package.repos`
68
+ // when the form starts with no `package`). `useFormValues`'s default-
69
+ // value effect would eventually backfill it to `[]`, but until then the
70
+ // old `values && ...` guard silently swallowed the click. Treat a
71
+ // missing array as empty so Add always produces `[defaultValue]`.
72
+ onValueChange(type, [...(values ?? []), defaultValue]);
73
+ }, [onValueChange, type, elementType, values]);
74
+
75
+ const handleDelete = useCallback(
76
+ (idx: number) => {
77
+ onValueChange(
78
+ type,
79
+ values?.filter((_, i) => i !== idx),
80
+ );
81
+ },
82
+ [onValueChange, type, values],
83
+ );
84
+
85
+ if (!elementType || ((readonly || layout === 'static') && values && values.length < 1)) {
86
+ return null;
87
+ }
88
+
89
+ const renderItemAsObject = elementType && isNestedType(elementType) && !Ref.isRefType(elementType);
90
+
91
+ return (
92
+ <>
93
+ {(layout !== 'static' || (values && values.length > 0)) && (
94
+ <div className='flex items-center gap-2'>
95
+ <div className='flex-1 min-w-0'>
96
+ <FormFieldLabel readonly={readonly} label={label} path={createJsonPath(path ?? [])} asChild />
97
+ </div>
98
+ {!readonly && layout !== 'static' && (
99
+ <IconBlock icon='ph--plus--regular' label={t('add-item.button')} onClick={handleAdd} />
100
+ )}
101
+ </div>
102
+ )}
103
+
104
+ <div className='flex flex-col'>
105
+ {values?.map((_, index) => {
106
+ const isLast = index === values.length - 1;
107
+ // Object items: each row contains a recursively-rendered FormField
108
+ // (multiple sub-rows for the object's fields) wrapped in a border,
109
+ // with the delete button bottom-aligned next to it.
110
+ // Scalar items: a single-row inline FormField with a center-aligned
111
+ // delete button. Refs and primitive arrays use this layout.
112
+ const fieldField = (
113
+ <FormField
114
+ {...props}
115
+ autoFocus={isLast}
116
+ type={elementType}
117
+ // Suppress the per-item header for object items only — the recursive
118
+ // form already renders labels for each sub-field. Scalar items
119
+ // (refs, primitives) keep the parent name so inline-layout
120
+ // children (e.g. RefField) have a real label to use as a
121
+ // fallback placeholder.
122
+ {...(renderItemAsObject && { name: null })}
123
+ path={[...(path ?? []), index]}
124
+ readonly={readonly || layout === 'static'}
125
+ layout={renderItemAsObject ? (layout === 'static' ? 'static' : undefined) : 'inline'}
126
+ />
127
+ );
128
+
129
+ return (
130
+ <div key={index} className='grid grid-cols-[1fr_min-content] items-center mb-1 last:mb-form-gap'>
131
+ {renderItemAsObject ? (
132
+ <div className='p-1 border border-subdued-separator'>{fieldField}</div>
133
+ ) : (
134
+ fieldField
135
+ )}
136
+
137
+ {!readonly && layout !== 'static' && (
138
+ <IconBlock icon='ph--x--regular' label={t('remove-item.button')} onClick={() => handleDelete(index)} />
139
+ )}
140
+ </div>
141
+ );
142
+ })}
143
+ </div>
144
+ </>
145
+ );
146
+ };
147
+
148
+ ArrayField.displayName = 'Form.ArrayField';
149
+
150
+ const IconBlock = (props: IconButtonProps) => {
151
+ return (
152
+ <div className='flex items-center h-full px-1'>
153
+ <IconButton variant='ghost' density='xs' square iconOnly {...props} />
154
+ </div>
155
+ );
156
+ };
157
+
158
+ /**
159
+ * Returns the default empty value for a given AST.
160
+ * Used for initializing new array values etc.
161
+ */
162
+ // TODO(wittjosiah): Factor out?
163
+ export const getDefaultValue = (ast?: SchemaAST.AST): any => {
164
+ switch (ast?._tag) {
165
+ case 'StringKeyword': {
166
+ return '';
167
+ }
168
+ case 'NumberKeyword': {
169
+ return 0;
170
+ }
171
+ case 'BooleanKeyword': {
172
+ return false;
173
+ }
174
+ case 'Suspend': {
175
+ return getDefaultValue(ast.f());
176
+ }
177
+ default: {
178
+ if (ast && isNestedType(ast)) {
179
+ return {};
180
+ } else {
181
+ throw new Error(`Unsupported type: ${ast?._tag}`);
182
+ }
183
+ }
184
+ }
185
+ };
@@ -0,0 +1,22 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import React, { useCallback } from 'react';
6
+
7
+ import { Input, type SwitchProps } from '@dxos/react-ui';
8
+
9
+ import { type FormFieldComponentProps, FormFieldWrapper } from '../FormFieldComponent';
10
+
11
+ export const BooleanField = ({ type, readonly, onValueChange, ...props }: FormFieldComponentProps<boolean>) => {
12
+ const handleChange = useCallback<NonNullable<SwitchProps['onCheckedChange']>>(
13
+ (value) => onValueChange?.(type, value),
14
+ [type, onValueChange],
15
+ );
16
+
17
+ return (
18
+ <FormFieldWrapper<boolean> readonly={readonly} {...props}>
19
+ {({ value }) => <Input.Switch disabled={!!readonly} checked={value} onCheckedChange={handleChange} />}
20
+ </FormFieldWrapper>
21
+ );
22
+ };
@@ -0,0 +1,95 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { format as formatDate } from 'date-fns';
6
+ import React, { useCallback } from 'react';
7
+
8
+ import { Format } from '@dxos/echo';
9
+ import { Input } from '@dxos/react-ui';
10
+
11
+ import { type FormFieldComponentProps, FormFieldWrapper } from '../FormFieldComponent';
12
+
13
+ /**
14
+ * Stored value shapes:
15
+ * - `Format.DateTime` -> ISO 8601 (`2018-11-13T20:20:39.000Z`).
16
+ * - `Format.Date` -> `YYYY-MM-DD`.
17
+ * - `Format.Time` -> `HH:mm:ss`.
18
+ *
19
+ * Segmented input value shapes (react-aria-components-backed):
20
+ * - `Input.DateTime` -> `YYYY-MM-DDTHH:mm` in local time.
21
+ * - `Input.Date` -> `YYYY-MM-DD`.
22
+ * - `Input.Time` -> `HH:mm`.
23
+ */
24
+
25
+ /** ISO 8601 → `YYYY-MM-DDTHH:mm` in the user's local timezone. */
26
+ const isoToLocalDateTime = (value: string | undefined): string => {
27
+ if (!value) {
28
+ return '';
29
+ }
30
+ const date = new Date(value);
31
+ if (Number.isNaN(date.getTime())) {
32
+ return '';
33
+ }
34
+ return formatDate(date, "yyyy-MM-dd'T'HH:mm");
35
+ };
36
+
37
+ /** `YYYY-MM-DDTHH:mm` (local) → ISO 8601 with timezone. */
38
+ const localDateTimeToIso = (value: string): string | undefined => {
39
+ if (!value) {
40
+ return undefined;
41
+ }
42
+ // Treat the input as local time. `new Date('YYYY-MM-DDTHH:mm')` parses as local.
43
+ const date = new Date(value);
44
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
45
+ };
46
+
47
+ export const DateField = ({
48
+ type,
49
+ format,
50
+ readonly,
51
+ placeholder: _placeholder,
52
+ onValueChange,
53
+ onBlur: _onBlur,
54
+ ...props
55
+ }: FormFieldComponentProps<string>) => {
56
+ const handleSimpleChange = useCallback((next: string) => onValueChange(type, next), [type, onValueChange]);
57
+
58
+ const handleDateTimeChange = useCallback(
59
+ (next: string) => {
60
+ const iso = localDateTimeToIso(next);
61
+ onValueChange(type, iso as string);
62
+ },
63
+ [type, onValueChange],
64
+ );
65
+
66
+ return (
67
+ <FormFieldWrapper<string> readonly={readonly} {...props}>
68
+ {({ value }) => {
69
+ switch (format) {
70
+ case Format.TypeFormat.Date:
71
+ return (
72
+ <div className='grid grid-cols-[1fr_min-content] items-center gap-1'>
73
+ <Input.Date disabled={!!readonly} value={value ?? ''} onValueChange={handleSimpleChange} />
74
+ <Input.TriggerIcon />
75
+ </div>
76
+ );
77
+ case Format.TypeFormat.Time:
78
+ return <Input.Time disabled={!!readonly} value={value ?? ''} onValueChange={handleSimpleChange} />;
79
+ case Format.TypeFormat.DateTime:
80
+ default:
81
+ return (
82
+ <div className='grid grid-cols-[1fr_min-content] items-center gap-1'>
83
+ <Input.DateTime
84
+ disabled={!!readonly}
85
+ value={isoToLocalDateTime(value)}
86
+ onValueChange={handleDateTimeChange}
87
+ />
88
+ <Input.TriggerIcon />
89
+ </div>
90
+ );
91
+ }
92
+ }}
93
+ </FormFieldWrapper>
94
+ );
95
+ };