@dxos/react-ui-form 0.10.0 → 0.11.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 (182) hide show
  1. package/dist/lib/index.mjs +4064 -0
  2. package/dist/lib/index.mjs.map +1 -0
  3. package/dist/lib/translations.mjs +77 -0
  4. package/dist/lib/translations.mjs.map +1 -0
  5. package/dist/lib/types.mjs +0 -0
  6. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +1 -0
  7. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  8. package/dist/types/src/components/Form/Card.stories.d.ts +88 -0
  9. package/dist/types/src/components/Form/Card.stories.d.ts.map +1 -0
  10. package/dist/types/src/components/Form/Form.d.ts +7 -7
  11. package/dist/types/src/components/Form/Form.stories.d.ts +21 -0
  12. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  13. package/dist/types/src/components/Form/Form.theme.d.ts +6 -0
  14. package/dist/types/src/components/Form/Form.theme.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/FormControls.d.ts +7 -7
  16. package/dist/types/src/components/Form/FormControls.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/FormField/FormField.d.ts +1 -1
  18. package/dist/types/src/components/Form/FormField/FormField.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/FormField/FormRow.d.ts +17 -3
  20. package/dist/types/src/components/Form/FormField/FormRow.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.d.ts +0 -6
  22. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts +1 -0
  24. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/FormField/fields/ArrayField/default-value.d.ts +7 -0
  26. package/dist/types/src/components/Form/FormField/fields/ArrayField/default-value.d.ts.map +1 -0
  27. package/dist/types/src/components/Form/FormField/fields/ArrayField/index.d.ts +1 -0
  28. package/dist/types/src/components/Form/FormField/fields/ArrayField/index.d.ts.map +1 -1
  29. package/dist/types/src/components/Form/FormField/fields/BooleanField/BooleanField.stories.d.ts +1 -0
  30. package/dist/types/src/components/Form/FormField/fields/BooleanField/BooleanField.stories.d.ts.map +1 -1
  31. package/dist/types/src/components/Form/FormField/fields/DateField/DateField.d.ts.map +1 -1
  32. package/dist/types/src/components/Form/FormField/fields/DateField/DateField.stories.d.ts +1 -0
  33. package/dist/types/src/components/Form/FormField/fields/DateField/DateField.stories.d.ts.map +1 -1
  34. package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.stories.d.ts +1 -0
  35. package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.stories.d.ts.map +1 -1
  36. package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.d.ts.map +1 -1
  37. package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.d.ts +1 -0
  38. package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.d.ts.map +1 -1
  39. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts +0 -11
  40. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts.map +1 -1
  41. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.stories.d.ts +1 -0
  42. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.stories.d.ts.map +1 -1
  43. package/dist/types/src/components/Form/FormField/fields/NumberField/index.d.ts +1 -0
  44. package/dist/types/src/components/Form/FormField/fields/NumberField/index.d.ts.map +1 -1
  45. package/dist/types/src/components/Form/FormField/fields/NumberField/numeric-constraints.d.ts +12 -0
  46. package/dist/types/src/components/Form/FormField/fields/NumberField/numeric-constraints.d.ts.map +1 -0
  47. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.d.ts.map +1 -1
  48. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.d.ts +1 -0
  49. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.d.ts.map +1 -1
  50. package/dist/types/src/components/Form/FormField/fields/RefField/RefField.d.ts +1 -11
  51. package/dist/types/src/components/Form/FormField/fields/RefField/RefField.d.ts.map +1 -1
  52. package/dist/types/src/components/Form/FormField/fields/RefField/RefField.stories.d.ts +1 -0
  53. package/dist/types/src/components/Form/FormField/fields/RefField/RefField.stories.d.ts.map +1 -1
  54. package/dist/types/src/components/Form/FormField/fields/RefField/find-ref-option.d.ts +13 -0
  55. package/dist/types/src/components/Form/FormField/fields/RefField/find-ref-option.d.ts.map +1 -0
  56. package/dist/types/src/components/Form/FormField/fields/RefField/index.d.ts +1 -0
  57. package/dist/types/src/components/Form/FormField/fields/RefField/index.d.ts.map +1 -1
  58. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts +1 -13
  59. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts.map +1 -1
  60. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.stories.d.ts +1 -0
  61. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.stories.d.ts.map +1 -1
  62. package/dist/types/src/components/Form/FormField/fields/SelectField/create-select-field.d.ts +15 -0
  63. package/dist/types/src/components/Form/FormField/fields/SelectField/create-select-field.d.ts.map +1 -0
  64. package/dist/types/src/components/Form/FormField/fields/SelectField/index.d.ts +1 -0
  65. package/dist/types/src/components/Form/FormField/fields/SelectField/index.d.ts.map +1 -1
  66. package/dist/types/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.d.ts.map +1 -1
  67. package/dist/types/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.stories.d.ts +1 -0
  68. package/dist/types/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.stories.d.ts.map +1 -1
  69. package/dist/types/src/components/Form/FormField/fields/TextAreaField/TextAreaField.stories.d.ts +1 -0
  70. package/dist/types/src/components/Form/FormField/fields/TextAreaField/TextAreaField.stories.d.ts.map +1 -1
  71. package/dist/types/src/components/Form/FormField/fields/TextField/TextField.stories.d.ts +1 -0
  72. package/dist/types/src/components/Form/FormField/fields/TextField/TextField.stories.d.ts.map +1 -1
  73. package/dist/types/src/components/Form/FormField/fields/TupleField/TupleField.stories.d.ts +1 -0
  74. package/dist/types/src/components/Form/FormField/fields/TupleField/TupleField.stories.d.ts.map +1 -1
  75. package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts +1 -1
  76. package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts.map +1 -1
  77. package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts +0 -27
  78. package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts.map +1 -1
  79. package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts +1 -0
  80. package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts.map +1 -1
  81. package/dist/types/src/components/Form/FormLayout/index.d.ts +1 -0
  82. package/dist/types/src/components/Form/FormLayout/index.d.ts.map +1 -1
  83. package/dist/types/src/components/Form/FormLayout/resolve-layout-field.d.ts +29 -0
  84. package/dist/types/src/components/Form/FormLayout/resolve-layout-field.d.ts.map +1 -0
  85. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +10 -1
  86. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -1
  87. package/dist/types/src/components/ObjectForm/ObjectForm.stories.d.ts +2 -0
  88. package/dist/types/src/components/ObjectForm/ObjectForm.stories.d.ts.map +1 -1
  89. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +8 -23
  90. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  91. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +1 -0
  92. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  93. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +1 -1
  94. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -1
  95. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +1 -0
  96. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -1
  97. package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts +1 -0
  98. package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts.map +1 -1
  99. package/dist/types/src/components/RefEditor/RefEditor.d.ts.map +1 -1
  100. package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts +2 -0
  101. package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts.map +1 -1
  102. package/dist/types/src/components/RefEditor/ref-editor-extension.d.ts +9 -8
  103. package/dist/types/src/components/RefEditor/ref-editor-extension.d.ts.map +1 -1
  104. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +1 -0
  105. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  106. package/dist/types/src/hooks/useFormHandler.d.ts +6 -2
  107. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  108. package/dist/types/src/hooks/useFormHandler.test.d.ts +2 -0
  109. package/dist/types/src/hooks/useFormHandler.test.d.ts.map +1 -0
  110. package/dist/types/src/testing/TestLayout.d.ts +1 -1
  111. package/dist/types/src/testing/TestLayout.d.ts.map +1 -1
  112. package/dist/types/src/translations.d.ts +1 -0
  113. package/dist/types/src/translations.d.ts.map +1 -1
  114. package/dist/types/src/types.d.ts +5 -0
  115. package/dist/types/src/types.d.ts.map +1 -1
  116. package/dist/types/src/util/index.d.ts +0 -1
  117. package/dist/types/src/util/index.d.ts.map +1 -1
  118. package/dist/types/tsconfig.tsbuildinfo +1 -1
  119. package/package.json +39 -42
  120. package/src/components/Form/Card.stories.tsx +93 -0
  121. package/src/components/Form/Form.stories.tsx +95 -3
  122. package/src/components/Form/Form.theme.ts +14 -8
  123. package/src/components/Form/Form.tsx +2 -2
  124. package/src/components/Form/FormControls.tsx +32 -13
  125. package/src/components/Form/FormField/FormField.tsx +11 -3
  126. package/src/components/Form/FormField/FormRow.tsx +20 -1
  127. package/src/components/Form/FormField/fields/ArrayField/ArrayField.tsx +3 -46
  128. package/src/components/Form/FormField/fields/ArrayField/default-value.ts +49 -0
  129. package/src/components/Form/FormField/fields/ArrayField/index.ts +2 -0
  130. package/src/components/Form/FormField/fields/ComboboxField/ComboboxField.tsx +12 -2
  131. package/src/components/Form/FormField/fields/DateField/DateField.tsx +10 -3
  132. package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.tsx +1 -1
  133. package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.tsx +2 -12
  134. package/src/components/Form/FormField/fields/NumberField/NumberField.test.ts +1 -1
  135. package/src/components/Form/FormField/fields/NumberField/NumberField.tsx +1 -36
  136. package/src/components/Form/FormField/fields/NumberField/index.ts +2 -0
  137. package/src/components/Form/FormField/fields/NumberField/numeric-constraints.ts +43 -0
  138. package/src/components/Form/FormField/fields/PasswordField/PasswordField.tsx +1 -0
  139. package/src/components/Form/FormField/fields/RefField/InlineRefField.tsx +2 -2
  140. package/src/components/Form/FormField/fields/RefField/RefField.test.ts +1 -1
  141. package/src/components/Form/FormField/fields/RefField/RefField.tsx +3 -45
  142. package/src/components/Form/FormField/fields/RefField/find-ref-option.ts +56 -0
  143. package/src/components/Form/FormField/fields/RefField/index.ts +2 -0
  144. package/src/components/Form/FormField/fields/SelectField/SelectField.tsx +2 -67
  145. package/src/components/Form/FormField/fields/SelectField/create-select-field.tsx +80 -0
  146. package/src/components/Form/FormField/fields/SelectField/index.ts +2 -0
  147. package/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.tsx +2 -1
  148. package/src/components/Form/FormFieldSet/FormFieldSetContainer.tsx +11 -9
  149. package/src/components/Form/FormLayout/FormLayout.test.ts +1 -1
  150. package/src/components/Form/FormLayout/FormLayout.tsx +1 -86
  151. package/src/components/Form/FormLayout/index.ts +2 -0
  152. package/src/components/Form/FormLayout/resolve-layout-field.ts +98 -0
  153. package/src/components/ObjectForm/ObjectForm.stories.tsx +93 -3
  154. package/src/components/ObjectForm/ObjectForm.tsx +22 -4
  155. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +1 -1
  156. package/src/components/ObjectPicker/ObjectPicker.tsx +3 -3
  157. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +1 -1
  158. package/src/components/ObjectProperties/ObjectProperties.tsx +7 -1
  159. package/src/components/ObjectTree/ObjectTree.stories.tsx +1 -1
  160. package/src/components/RefEditor/RefEditor.stories.tsx +9 -1
  161. package/src/components/RefEditor/RefEditor.tsx +42 -6
  162. package/src/components/RefEditor/ref-editor-extension.ts +168 -91
  163. package/src/components/ViewEditor/ViewEditor.stories.tsx +1 -1
  164. package/src/components/ViewEditor/ViewEditor.tsx +9 -9
  165. package/src/hooks/useFormHandler.test.ts +85 -0
  166. package/src/hooks/useFormHandler.ts +118 -31
  167. package/src/translations.ts +1 -0
  168. package/src/types.ts +5 -0
  169. package/src/util/index.ts +0 -1
  170. package/dist/lib/browser/index.mjs +0 -4675
  171. package/dist/lib/browser/index.mjs.map +0 -7
  172. package/dist/lib/browser/meta.json +0 -1
  173. package/dist/lib/browser/translations.mjs +0 -87
  174. package/dist/lib/browser/translations.mjs.map +0 -7
  175. package/dist/lib/node-esm/index.mjs +0 -4677
  176. package/dist/lib/node-esm/index.mjs.map +0 -7
  177. package/dist/lib/node-esm/meta.json +0 -1
  178. package/dist/lib/node-esm/translations.mjs +0 -89
  179. package/dist/lib/node-esm/translations.mjs.map +0 -7
  180. package/dist/types/src/util/formatting.d.ts +0 -15
  181. package/dist/types/src/util/formatting.d.ts.map +0 -1
  182. package/src/util/formatting.ts +0 -171
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dxos/react-ui-form",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Data components.",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
@@ -19,14 +19,12 @@
19
19
  ".": {
20
20
  "source": "./src/index.ts",
21
21
  "types": "./dist/types/src/index.d.ts",
22
- "browser": "./dist/lib/browser/index.mjs",
23
- "node": "./dist/lib/node-esm/index.mjs"
22
+ "import": "./dist/lib/index.mjs"
24
23
  },
25
24
  "./translations": {
26
25
  "source": "./src/translations.ts",
27
26
  "types": "./dist/types/src/translations.d.ts",
28
- "browser": "./dist/lib/browser/translations.mjs",
29
- "node": "./dist/lib/node-esm/translations.mjs"
27
+ "import": "./dist/lib/translations.mjs"
30
28
  }
31
29
  },
32
30
  "types": "dist/types/src/index.d.ts",
@@ -36,36 +34,35 @@
36
34
  ],
37
35
  "dependencies": {
38
36
  "@codemirror/lang-xml": "^6.1.0",
39
- "@codemirror/state": "^6.6.0",
40
- "@codemirror/view": "^6.43.0",
37
+ "@codemirror/state": "^6.7.1",
38
+ "@codemirror/view": "^6.43.6",
41
39
  "@effect-atom/atom-react": "^0.5.0",
42
40
  "@fluentui/react-tabster": "9.26.11",
43
41
  "@radix-ui/react-context": "1.1.1",
44
- "@radix-ui/react-use-controllable-state": "1.1.0",
45
42
  "date-fns": "^3.6.0",
46
43
  "tabster": "^8.5.5",
47
- "@dxos/async": "0.10.0",
48
- "@dxos/echo-doc": "0.10.0",
49
- "@dxos/echo": "0.10.0",
50
- "@dxos/echo-protocol": "0.10.0",
51
- "@dxos/echo-react": "0.10.0",
52
- "@dxos/keys": "0.10.0",
53
- "@dxos/log": "0.10.0",
54
- "@dxos/lit-ui": "0.10.0",
55
- "@dxos/effect": "0.10.0",
56
- "@dxos/react-client": "0.10.0",
57
- "@dxos/react-ui-components": "0.10.0",
58
- "@dxos/react-ui-list": "0.10.0",
59
- "@dxos/invariant": "0.10.0",
60
- "@dxos/react-ui-editor": "0.10.0",
61
- "@dxos/react-ui-search": "0.10.0",
62
- "@dxos/react-ui-markdown": "0.10.0",
63
- "@dxos/schema": "0.10.0",
64
- "@dxos/types": "0.10.0",
65
- "@dxos/react-ui-pickers": "0.10.0",
66
- "@dxos/ui": "0.10.0",
67
- "@dxos/ui-editor": "0.10.0",
68
- "@dxos/util": "0.10.0"
44
+ "@dxos/echo-doc": "0.11.0",
45
+ "@dxos/async": "0.11.0",
46
+ "@dxos/echo": "0.11.0",
47
+ "@dxos/echo-protocol": "0.11.0",
48
+ "@dxos/effect": "0.11.0",
49
+ "@dxos/echo-react": "0.11.0",
50
+ "@dxos/invariant": "0.11.0",
51
+ "@dxos/keys": "0.11.0",
52
+ "@dxos/lit-ui": "0.11.0",
53
+ "@dxos/log": "0.11.0",
54
+ "@dxos/react-client": "0.11.0",
55
+ "@dxos/react-ui-list": "0.11.0",
56
+ "@dxos/react-ui-markdown": "0.11.0",
57
+ "@dxos/react-ui-components": "0.11.0",
58
+ "@dxos/react-ui-pickers": "0.11.0",
59
+ "@dxos/react-ui-search": "0.11.0",
60
+ "@dxos/react-ui-editor": "0.11.0",
61
+ "@dxos/schema": "0.11.0",
62
+ "@dxos/types": "0.11.0",
63
+ "@dxos/ui": "0.11.0",
64
+ "@dxos/ui-editor": "0.11.0",
65
+ "@dxos/util": "0.11.0"
69
66
  },
70
67
  "devDependencies": {
71
68
  "@types/react": "~19.2.17",
@@ -74,23 +71,23 @@
74
71
  "fast-check": "^3.19.0",
75
72
  "react": "~19.2.7",
76
73
  "react-dom": "~19.2.7",
77
- "vite": "^8.0.16",
78
- "@dxos/effect-proto": "0.10.0",
79
- "@dxos/protocols": "0.10.0",
80
- "@dxos/random": "0.10.0",
81
- "@dxos/react-ui": "0.10.0",
82
- "@dxos/react-client": "0.10.0",
83
- "@dxos/react-ui-syntax-highlighter": "0.10.0",
84
- "@dxos/storybook-utils": "0.10.0",
85
- "@dxos/ui-theme": "0.10.0",
86
- "@dxos/ui-types": "0.10.0"
74
+ "vite": "^8.1.4",
75
+ "@dxos/effect-proto": "0.11.0",
76
+ "@dxos/protocols": "0.11.0",
77
+ "@dxos/random": "0.11.0",
78
+ "@dxos/react-client": "0.11.0",
79
+ "@dxos/react-ui": "0.11.0",
80
+ "@dxos/react-ui-syntax-highlighter": "0.11.0",
81
+ "@dxos/ui-theme": "0.11.0",
82
+ "@dxos/ui-types": "0.11.0",
83
+ "@dxos/storybook-utils": "0.11.0"
87
84
  },
88
85
  "peerDependencies": {
89
86
  "effect": "3.21.4",
90
87
  "react": "~19.2.7",
91
88
  "react-dom": "~19.2.7",
92
- "@dxos/ui-theme": "0.10.0",
93
- "@dxos/react-ui": "0.10.0"
89
+ "@dxos/react-ui": "^0.11.0",
90
+ "@dxos/ui-theme": "^0.11.0"
94
91
  },
95
92
  "publishConfig": {
96
93
  "access": "public"
@@ -0,0 +1,93 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import * as Schema from 'effect/Schema';
7
+ import React from 'react';
8
+
9
+ import { Card, Icon } from '@dxos/react-ui';
10
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
+
12
+ import { translations } from '#translations';
13
+
14
+ import { Form } from './Form';
15
+
16
+ /**
17
+ * Alignment harness for a form hosted inside a Card.
18
+ *
19
+ * `Form.Viewport` used to declare its own gutter grid, so a hosted form's fields inset by the
20
+ * form's gutter (8px) while the card's own rows inset by the card's (32px) — the two never lined
21
+ * up, and call sites grew three different workarounds. It now detects the host Column and places
22
+ * the body in the host's content track instead.
23
+ *
24
+ * Read the story by the vertical edges: the reference card's rows and the form card's fields
25
+ * should share one left edge, and the trailing edge should likewise agree.
26
+ */
27
+
28
+ const Contact = Schema.Struct({
29
+ name: Schema.String.annotations({ title: 'Full name' }),
30
+ email: Schema.String.annotations({ title: 'Email' }),
31
+ role: Schema.optional(Schema.String.annotations({ title: 'Role' })),
32
+ });
33
+
34
+ const values = { name: 'Ada Lovelace', email: 'ada@example.com', role: 'Engineer' };
35
+
36
+ const ReferenceCard = () => (
37
+ <Card.Root fullWidth>
38
+ <Card.Header>
39
+ <Card.Block>
40
+ <Icon icon='ph--user--regular' />
41
+ </Card.Block>
42
+ <Card.Title>Reference card</Card.Title>
43
+ </Card.Header>
44
+ <Card.Body>
45
+ <Card.Row>
46
+ <Card.Text>A card row — the inset to match.</Card.Text>
47
+ </Card.Row>
48
+ <Card.Row>
49
+ <Card.Text variant='description'>Second row, same track.</Card.Text>
50
+ </Card.Row>
51
+ </Card.Body>
52
+ </Card.Root>
53
+ );
54
+
55
+ const FormCard = () => (
56
+ <Card.Root fullWidth>
57
+ <Card.Header>
58
+ <Card.Block>
59
+ <Icon icon='ph--pencil--regular' />
60
+ </Card.Block>
61
+ <Card.Title>Form card</Card.Title>
62
+ </Card.Header>
63
+ <Card.Body>
64
+ <Form.Root schema={Contact} values={values}>
65
+ <Form.Viewport>
66
+ <Form.Content>
67
+ <Form.FieldSet />
68
+ </Form.Content>
69
+ </Form.Viewport>
70
+ </Form.Root>
71
+ </Card.Body>
72
+ </Card.Root>
73
+ );
74
+
75
+ const DefaultStory = () => (
76
+ <div className='flex flex-col gap-4 w-96'>
77
+ <ReferenceCard />
78
+ <FormCard />
79
+ </div>
80
+ );
81
+
82
+ const meta = {
83
+ title: 'ui/react-ui-form/Card',
84
+ render: DefaultStory,
85
+ decorators: [withTheme(), withLayout({ layout: 'centered' })],
86
+ parameters: { translations },
87
+ } satisfies Meta<typeof DefaultStory>;
88
+
89
+ export default meta;
90
+
91
+ type Story = StoryObj<typeof meta>;
92
+
93
+ export const Default: Story = {};
@@ -5,14 +5,15 @@
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
6
  import * as Effect from 'effect/Effect';
7
7
  import * as Schema from 'effect/Schema';
8
- import React, { useCallback, useMemo, useState } from 'react';
8
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
9
9
 
10
10
  import { Annotation, Filter, Format, Obj, Ref, Tag, Type } from '@dxos/echo';
11
+ import { useQuery } from '@dxos/echo-react';
11
12
  import { type AnyProperties } from '@dxos/echo/internal';
12
13
  import { log } from '@dxos/log';
13
- import { useQuery, useSpaces } from '@dxos/react-client/echo';
14
+ import { useSpaces } from '@dxos/react-client/echo';
14
15
  import { withClientProvider } from '@dxos/react-client/testing';
15
- import { Tooltip } from '@dxos/react-ui';
16
+ import { Toolbar, Tooltip } from '@dxos/react-ui';
16
17
  import { Loading, withLayout, withTheme } from '@dxos/react-ui/testing';
17
18
  import { Text } from '@dxos/schema';
18
19
 
@@ -394,3 +395,94 @@ export const DiscriminatedUnion: Story<Schema.Schema.Type<typeof CreateFeedSchem
394
395
  schema: CreateFeedSchema,
395
396
  },
396
397
  };
398
+
399
+ // Reactive-source + local-buffer pattern: `values` is a live source, and a parent that gates persistence on
400
+ // validity (like the selected-objects `ObjectForm`). `counter` ticks once a second from outside the form to
401
+ // stand in for an external mutation.
402
+ const ReactiveSchema = Schema.mutable(
403
+ Schema.Struct({
404
+ name: Schema.NonEmptyString.annotations({
405
+ title: 'Name',
406
+ description: 'Required — clear it and the form holds the invalid draft instead of snapping back.',
407
+ }),
408
+ counter: Schema.Number.annotations({
409
+ title: 'Counter',
410
+ description: 'Ticks every second from an external source; updates live even while you edit Name.',
411
+ }),
412
+ }),
413
+ );
414
+ type ReactiveValues = Schema.Schema.Type<typeof ReactiveSchema>;
415
+
416
+ const ReactiveBufferedStory = () => {
417
+ const [source, setSource] = useState<Partial<ReactiveValues>>({ name: 'Alice', counter: 0 });
418
+ const [running, setRunning] = useState(true);
419
+
420
+ const tick = useCallback(() => setSource((prev) => ({ ...prev, counter: (prev.counter ?? 0) + 1 })), []);
421
+
422
+ // External source of truth: tick the counter outside the form while running. Stop it to edit the counter by hand.
423
+ useEffect(() => {
424
+ if (!running) {
425
+ return;
426
+ }
427
+ const interval = setInterval(tick, 1000);
428
+ return () => clearInterval(interval);
429
+ }, [running, tick]);
430
+
431
+ // Persist only valid changes (mirrors ObjectForm): invalid intermediate edits stay buffered in the form.
432
+ const handleValuesChanged = useCallback<NonNullable<FormRootProps<ReactiveValues>['onValuesChanged']>>(
433
+ (values, { isValid }) => {
434
+ if (!isValid) {
435
+ return;
436
+ }
437
+ setSource((prev) => ({ ...prev, ...values }));
438
+ },
439
+ [],
440
+ );
441
+
442
+ return (
443
+ <Tooltip.Provider>
444
+ <TestLayout json={{ source }}>
445
+ <div className='flex flex-col h-full'>
446
+ <Toolbar.Root>
447
+ <Toolbar.IconButton
448
+ icon={running ? 'ph--pause--regular' : 'ph--play--regular'}
449
+ label={running ? 'Stop external ticks' : 'Start external ticks'}
450
+ onClick={() => setRunning((value) => !value)}
451
+ />
452
+ <Toolbar.IconButton
453
+ icon='ph--plus--regular'
454
+ label='Tick counter once (external)'
455
+ disabled={running}
456
+ onClick={tick}
457
+ />
458
+ </Toolbar.Root>
459
+ <Form.Root schema={ReactiveSchema} values={source} onValuesChanged={handleValuesChanged}>
460
+ <Form.Viewport>
461
+ <Form.Content>
462
+ <Form.FieldSet />
463
+ </Form.Content>
464
+ </Form.Viewport>
465
+ </Form.Root>
466
+ </div>
467
+ </TestLayout>
468
+ </Tooltip.Provider>
469
+ );
470
+ };
471
+
472
+ /**
473
+ * Demonstrates the reactive-source + local-buffer pattern used by the selected-objects companion. Pass a live
474
+ * object as `values` and gate persistence on validity in `onValuesChanged`. Watch the `Counter` field update
475
+ * live from the external tick while you clear `Name`: the form holds the invalid draft (shows the error) without
476
+ * snapping back, and the external counter keeps flowing in. Fixing `Name` commits it and reconciles.
477
+ *
478
+ * Use the toolbar to stop the external ticks (then edit `Counter` by hand, or step it once with the `+` button)
479
+ * to see manual edits and external updates reconcile through the same path.
480
+ *
481
+ * @idiom org.dxos.react-ui-form.reactiveBufferedForm
482
+ * applies: Editing a live/remote object where fields can change externally mid-edit (object/article editors, companions)
483
+ * instead-of: Uncontrolled `defaultValues` (ignores external changes) or naive controlled `values` (snaps invalid input back)
484
+ * uses: {@link Form.Root}
485
+ */
486
+ export const ReactiveBuffered: Story<ReactiveValues> = {
487
+ render: () => <ReactiveBufferedStory />,
488
+ };
@@ -13,15 +13,18 @@ export type FormBehavior = { showDescription: boolean };
13
13
  const formStyles = tv({
14
14
  slots: {
15
15
  viewport: '',
16
- content: '',
16
+ // Bottom padding on the body, so the last field never sits flush against its host's edge
17
+ // (a form in a card, a dialog body, a scrolled panel all need it).
18
+ content: 'pb-form-padding',
17
19
  section: 'flex flex-col py-form-section-gap first:pt-0',
18
- group: 'flex flex-col gap-3 p-trim-md border border-separator rounded-sm',
20
+ group: 'flex flex-col gap-trim-md p-trim-md border border-separator rounded-sm',
19
21
  sectionHeader: '',
20
22
  sectionTitle: 'text-lg',
21
23
  sectionDescription: 'text-description',
22
24
  fieldSet: '',
23
25
  field: '',
24
- fieldLabel: 'h-8 grid grid-cols-[1fr_auto_auto] items-center select-none',
26
+ // Columns: label (fills) → optional `labelEnd` readout → error icon (or its spacer) → optional trailing `button`.
27
+ fieldLabel: 'min-h-(--dx-control) grid grid-cols-[1fr_auto_auto_auto] items-center select-none',
25
28
  fieldLabelText: '',
26
29
  fieldDescription: 'text-description',
27
30
  fieldControl: '',
@@ -31,10 +34,10 @@ const formStyles = tv({
31
34
  // Standalone submit row (full-width primary button).
32
35
  submit: 'flex w-full pt-form-padding',
33
36
  // Collapsible field-set body: indented column of sub-fields.
34
- fieldSetBody: 'flex flex-col gap-2 px-2 pb-2',
37
+ fieldSetBody: 'flex flex-col px-trim-sm pb-trim-sm',
35
38
  // Bordered container wrapping a collapsible nested group, plus its top spacing.
36
39
  fieldSetBox: 'border border-subdued-separator rounded-sm',
37
- fieldSetBoxOuter: 'pt-trim-md',
40
+ fieldSetBoxOuter: '',
38
41
  },
39
42
  variants: {
40
43
  variant: {
@@ -49,16 +52,19 @@ const formStyles = tv({
49
52
  sectionDescription: 'px-trim-md',
50
53
  // No top padding: the section gap already separates the field set from the title above it.
51
54
  fieldSet: 'flex flex-col gap-trim-md',
55
+ // Nested groups render their sub-fields through `fieldSetBody`, so it needs the same gap as
56
+ // `fieldSet` — otherwise fields inside a group sit flush while their top-level siblings don't.
57
+ fieldSetBody: 'flex flex-col gap-trim-md px-trim-sm pb-trim-sm',
52
58
  field: mx(
53
59
  'grid',
54
60
  'grid-cols-1 [grid-template-areas:"header""description""control""validation"]',
55
61
  'md:grid-cols-2 md:[grid-template-areas:"header_header""description_control""validation_validation"]',
56
- 'gap-x-trim-lg gap-y-0 p-trim-md border border-input-separator rounded-md',
62
+ 'gap-x-trim-xl gap-y-0 p-trim-md border border-input-separator rounded-md',
57
63
  ),
58
64
  fieldLabel: '[grid-area:header]',
59
65
  fieldLabelText: 'text-base-fg text-lg',
60
- fieldDescription: '[grid-area:description] pt-1 text-description',
61
- fieldControl: '[grid-area:control] flex justify-end items-start md:text-right pt-3 md:pt-0',
66
+ fieldDescription: '[grid-area:description] pt-trim-xs text-description',
67
+ fieldControl: '[grid-area:control] flex justify-end items-start pt-trim-md md:pt-0',
62
68
  fieldValidation: '[grid-area:validation]',
63
69
  },
64
70
  },
@@ -8,7 +8,7 @@ import {
8
8
  FormContent,
9
9
  type FormContentProps,
10
10
  FormError,
11
- FormFieldSetController,
11
+ FormFieldSetContainer,
12
12
  FormGroup,
13
13
  type FormGroupProps,
14
14
  FormLayoutController,
@@ -30,7 +30,7 @@ export const Form = {
30
30
  Content: FormContent,
31
31
  Section: FormSection,
32
32
  Group: FormGroup,
33
- FieldSet: FormFieldSetController,
33
+ FieldSet: FormFieldSetContainer,
34
34
  Layout: FormLayoutController,
35
35
  Label: FormFieldLabel,
36
36
  Row: FormRow,
@@ -15,6 +15,7 @@ import {
15
15
  type ThemedClassName,
16
16
  composable,
17
17
  composableProps,
18
+ useInColumn,
18
19
  useMergeRefs,
19
20
  useTranslation,
20
21
  withColumn,
@@ -59,7 +60,6 @@ export type FormRootProps<T extends AnyProperties = AnyProperties> = Merge<
59
60
  Omit<NaturalFormFieldSetProps<T>, 'schema' | 'path'>
60
61
  >;
61
62
 
62
- // `variant` (from FormContextValue) flows through `...props` into the context for all parts to read.
63
63
  export const FormRoot = <T extends AnyProperties = AnyProperties>({
64
64
  children,
65
65
  schema,
@@ -96,20 +96,39 @@ export const FormViewport = composable<HTMLDivElement, FormViewportProps>(
96
96
  ({ children, scroll, gutter = 'sm', ...props }, forwardedRef) => {
97
97
  const { variant = 'default' } = useFormContext(FORM_VIEWPORT_NAME);
98
98
  const styles = formTheme.styles({ variant });
99
+ const inColumn = useInColumn();
99
100
  // Span the full width when nested inside another Column grid (e.g. Card.Root)
100
101
  // instead of landing in a single narrow track.
101
102
  const span = '[.dx-column-root_&]:col-span-full';
103
+
104
+ // Inside a host Column (a Card, a Dialog body), the host already owns the gutter: place the
105
+ // body in its content track rather than nesting a second grid, which is what made form fields
106
+ // inset differently from the card title above them. Non-scrolling only — a scrolling viewport
107
+ // needs its own gutter to host the scrollbar.
108
+ if (inColumn && !scroll) {
109
+ return (
110
+ <Column.Center
111
+ {...composableProps(props, { classNames: ['w-full min-w-0', styles.viewport()] })}
112
+ ref={forwardedRef}
113
+ >
114
+ {children}
115
+ </Column.Center>
116
+ );
117
+ }
118
+
119
+ // Forwarded props land on the OUTERMOST element, never on the inner ScrollArea. A host places
120
+ // this component by passing placement down (`Panel.Content asChild` merges `[grid-area:content]`
121
+ // into it); on an inner element that name belongs to no line of the enclosing Column grid, and
122
+ // CSS answers an unmatched name by fabricating an implicit row rather than ignoring it — which
123
+ // silently pushed the form into a phantom second row (AUDIT.md §3).
102
124
  if (scroll) {
103
125
  return (
104
- <Column.Root gutter={gutter} classNames={['dx-expander', span]}>
105
- <ScrollArea.Root
106
- {...composableProps(props, { classNames: styles.viewport() })}
107
- orientation='vertical'
108
- centered
109
- padding
110
- thin
111
- ref={forwardedRef}
112
- >
126
+ <Column.Root
127
+ {...composableProps(props, { classNames: ['dx-expander', span, styles.viewport()] })}
128
+ gutter={gutter}
129
+ ref={forwardedRef}
130
+ >
131
+ <ScrollArea.Root orientation='vertical' centered padding thin>
113
132
  <ScrollArea.Viewport>{children}</ScrollArea.Viewport>
114
133
  </ScrollArea.Root>
115
134
  </Column.Root>
@@ -168,10 +187,10 @@ FormContent.displayName = FORM_CONTENT_NAME;
168
187
 
169
188
  const FORM_FIELDSET_NAME = 'Form.FieldSet';
170
189
 
171
- export type FormFieldSetControllerProps = ThemedClassName<NaturalFormFieldSetProps<any>>;
190
+ export type FormFieldSetContainerProps = ThemedClassName<NaturalFormFieldSetProps<any>>;
172
191
 
173
192
  /** Context-reading binding for `Form.FieldSet`: pulls the schema + field context off the form and delegates to {@link FormFieldSet}. */
174
- export const FormFieldSetController = ({ classNames, ...props }: FormFieldSetControllerProps) => {
193
+ export const FormFieldSetContainer = ({ classNames, ...props }: FormFieldSetContainerProps) => {
175
194
  const { form, variant = 'default', ...contextProps } = useFormContext(FORM_FIELDSET_NAME);
176
195
  const styles = formTheme.styles({ variant });
177
196
  return (
@@ -184,7 +203,7 @@ export const FormFieldSetController = ({ classNames, ...props }: FormFieldSetCon
184
203
  );
185
204
  };
186
205
 
187
- FormFieldSetController.displayName = FORM_FIELDSET_NAME;
206
+ FormFieldSetContainer.displayName = FORM_FIELDSET_NAME;
188
207
 
189
208
  //
190
209
  // Layout
@@ -12,6 +12,7 @@ import React, { useMemo } from 'react';
12
12
  import { Annotation, Format } from '@dxos/echo';
13
13
  import { SchemaEx } from '@dxos/effect';
14
14
  import { IconButton, IconButtonProps, useTranslation } from '@dxos/react-ui';
15
+ import { mx } from '@dxos/ui-theme';
15
16
 
16
17
  import { translationKey } from '#translations';
17
18
  import { type FieldContext, type FormFieldRenderer, type FormFieldRendererProps } from '#types';
@@ -87,6 +88,7 @@ export const FormField = (props: FormFieldProps) => {
87
88
  fieldMap,
88
89
  fieldProvider,
89
90
  readonly,
91
+ hideEmpty = true,
90
92
  layout,
91
93
 
92
94
  // RefFieldProps
@@ -144,7 +146,7 @@ export const FormField = (props: FormFieldProps) => {
144
146
  // (`ArrayField`, nested-struct -> `FormFieldSet`) keep their own
145
147
  // empty-value checks, but those branches only apply when the value is
146
148
  // actually a non-null array/object, so this check doesn't interfere.
147
- if (readonly && fieldState.getValue() == null) {
149
+ if (readonly && hideEmpty && fieldState.getValue() == null) {
148
150
  return null;
149
151
  }
150
152
 
@@ -310,8 +312,14 @@ FormField.displayName = 'Form.FormField';
310
312
  // Layout components
311
313
  //
312
314
 
313
- export const CompactIconButton = (props: IconButtonProps) => {
314
- return <IconButton variant='ghost' iconOnly {...props} />;
315
+ // End-of-row form buttons occupy a consistent 32px block (matching the standard `h-8` label row) with
316
+ // the button inset so its hover fill never touches the row's top/bottom/right edges.
317
+ export const CompactIconButton = ({ classNames, ...props }: IconButtonProps) => {
318
+ return (
319
+ <span className='grid size-8 shrink-0 place-items-center'>
320
+ <IconButton variant='ghost' iconOnly {...props} classNames={mx('min-h-0 h-7 w-7 p-0', classNames)} />
321
+ </span>
322
+ );
315
323
  };
316
324
 
317
325
  /**
@@ -34,10 +34,17 @@ export type FormFieldLabelProps = ThemedClassName<
34
34
  */
35
35
  path?: string;
36
36
  /**
37
- * Trailing button rendered at the end of the label row (third grid column).
37
+ * Trailing button rendered at the end of the label row (last grid column).
38
38
  * Used by nested-object field sets to surface a collapse toggle.
39
39
  */
40
40
  button?: ReactNode;
41
+ /**
42
+ * Read-only content rendered right after the label text (e.g. a live numeric readout for a
43
+ * slider field) — a sibling of `Input.Label`, never a child, so the label's `textContent`
44
+ * stays exactly `label` (see the comment on `labelClassNames` below) and doesn't masquerade
45
+ * as an interactive control the way `button` does.
46
+ */
47
+ labelEnd?: ReactNode;
41
48
  onClick?: () => void;
42
49
  } & Pick<FormFieldRendererProps, 'label' | 'readonly' | 'required'>
43
50
  >;
@@ -52,6 +59,7 @@ export const FormFieldLabel = ({
52
59
  required,
53
60
  standalone,
54
61
  button,
62
+ labelEnd,
55
63
  onClick,
56
64
  }: FormFieldLabelProps) => {
57
65
  const styles = formTheme.styles({ variant });
@@ -77,6 +85,7 @@ export const FormFieldLabel = ({
77
85
  return (
78
86
  <div className={styles.fieldLabel({ class: mx(onClick && 'cursor-pointer', classNames) })} onClick={onClick}>
79
87
  {labelNode}
88
+ {labelEnd}
80
89
  {error ? (
81
90
  <Tooltip.Trigger asChild content={error} side='bottom'>
82
91
  <Icon icon='ph--warning--regular' size={4} classNames='text-error-text' />
@@ -135,6 +144,13 @@ export type FormRowProps<T = any> = ThemedClassName<
135
144
  * Render the label as a standalone `<span>` (group/multi-input fields with no single associated control).
136
145
  */
137
146
  standalone?: boolean;
147
+ /**
148
+ * Read-only content rendered right after the label text, right-aligned by the label row's
149
+ * grid (e.g. a live numeric readout for a slider field). Forwarded to `FormFieldLabel`'s
150
+ * `labelEnd` slot — a sibling of the label, never a child, so the label's `textContent` and
151
+ * accessible name stay exactly `label`.
152
+ */
153
+ labelEnd?: ReactNode;
138
154
  /**
139
155
  * Override the read-only/`static` rendering of the value. Fields whose value is not plain text
140
156
  * (refs, selects, markdown) supply this; the default formats scalars/dates via `formatStaticValue`.
@@ -175,6 +191,7 @@ export const FormRow = <T,>({
175
191
  format,
176
192
  required,
177
193
  standalone,
194
+ labelEnd,
178
195
  renderStatic,
179
196
  validation,
180
197
  getStatus,
@@ -210,6 +227,7 @@ export const FormRow = <T,>({
210
227
  readonly={readonly}
211
228
  required={required}
212
229
  standalone={standalone}
230
+ labelEnd={labelEnd}
213
231
  label={label}
214
232
  path={jsonPath}
215
233
  />
@@ -241,6 +259,7 @@ export const FormRow = <T,>({
241
259
  readonly={readonly}
242
260
  required={required}
243
261
  standalone
262
+ labelEnd={labelEnd}
244
263
  label={label}
245
264
  path={jsonPath}
246
265
  />