@dxos/react-ui-form 0.9.1-main.c7dcc2e112 → 0.10.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 (211) hide show
  1. package/dist/lib/browser/index.mjs +1883 -1539
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +1883 -1539
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/annotations.d.ts +47 -0
  8. package/dist/types/src/annotations.d.ts.map +1 -0
  9. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  10. package/dist/types/src/components/Form/Form.d.ts +12 -4
  11. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  12. package/dist/types/src/components/Form/Form.stories.d.ts +46 -65
  13. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  14. package/dist/types/src/components/Form/Form.theme.d.ts +252 -0
  15. package/dist/types/src/components/Form/Form.theme.d.ts.map +1 -0
  16. package/dist/types/src/components/Form/Form.theme.test.d.ts +2 -0
  17. package/dist/types/src/components/Form/Form.theme.test.d.ts.map +1 -0
  18. package/dist/types/src/components/Form/FormControls.d.ts +17 -8
  19. package/dist/types/src/components/Form/FormControls.d.ts.map +1 -1
  20. package/dist/types/src/components/Form/FormField/FormField.d.ts +16 -2
  21. package/dist/types/src/components/Form/FormField/FormField.d.ts.map +1 -1
  22. package/dist/types/src/components/Form/FormField/FormFieldHeader.d.ts +27 -0
  23. package/dist/types/src/components/Form/FormField/FormFieldHeader.d.ts.map +1 -0
  24. package/dist/types/src/components/Form/FormField/FormRow.d.ts +85 -0
  25. package/dist/types/src/components/Form/FormField/FormRow.d.ts.map +1 -0
  26. package/dist/types/src/components/Form/FormField/{FormFieldWrapper.stories.d.ts → FormRow.stories.d.ts} +4 -1
  27. package/dist/types/src/components/Form/FormField/FormRow.stories.d.ts.map +1 -0
  28. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.d.ts.map +1 -1
  29. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts +1 -0
  30. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts.map +1 -1
  31. package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/AsyncSelectField.d.ts +12 -0
  32. package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/AsyncSelectField.d.ts.map +1 -0
  33. package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/index.d.ts +2 -0
  34. package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/index.d.ts.map +1 -0
  35. package/dist/types/src/components/Form/FormField/fields/AutofillField/AutofillField.d.ts +12 -0
  36. package/dist/types/src/components/Form/FormField/fields/AutofillField/AutofillField.d.ts.map +1 -0
  37. package/dist/types/src/components/Form/FormField/fields/AutofillField/index.d.ts +2 -0
  38. package/dist/types/src/components/Form/FormField/fields/AutofillField/index.d.ts.map +1 -0
  39. package/dist/types/src/components/Form/FormField/fields/BooleanField/BooleanField.d.ts.map +1 -1
  40. package/dist/types/src/components/Form/FormField/fields/ComboboxField/ComboboxField.d.ts +12 -0
  41. package/dist/types/src/components/Form/FormField/fields/ComboboxField/ComboboxField.d.ts.map +1 -0
  42. package/dist/types/src/components/Form/FormField/fields/ComboboxField/index.d.ts +2 -0
  43. package/dist/types/src/components/Form/FormField/fields/ComboboxField/index.d.ts.map +1 -0
  44. package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.d.ts +1 -1
  45. package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.d.ts.map +1 -1
  46. package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.d.ts +1 -1
  47. package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.d.ts.map +1 -1
  48. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts +11 -0
  49. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts.map +1 -1
  50. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.test.d.ts +2 -0
  51. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.test.d.ts.map +1 -0
  52. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.d.ts +4 -0
  53. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.d.ts.map +1 -0
  54. package/dist/types/src/components/{Settings/Settings.stories.d.ts → Form/FormField/fields/PasswordField/PasswordField.stories.d.ts} +1 -2
  55. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.d.ts.map +1 -0
  56. package/dist/types/src/components/Form/FormField/fields/PasswordField/index.d.ts +2 -0
  57. package/dist/types/src/components/Form/FormField/fields/PasswordField/index.d.ts.map +1 -0
  58. package/dist/types/src/components/Form/FormField/fields/RefField/InlineRefField.d.ts.map +1 -1
  59. package/dist/types/src/components/Form/FormField/fields/RefField/RefField.d.ts.map +1 -1
  60. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts +18 -2
  61. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts.map +1 -1
  62. package/dist/types/src/components/Form/FormField/fields/index.d.ts +4 -0
  63. package/dist/types/src/components/Form/FormField/fields/index.d.ts.map +1 -1
  64. package/dist/types/src/components/Form/FormField/index.d.ts +4 -3
  65. package/dist/types/src/components/Form/FormField/index.d.ts.map +1 -1
  66. package/dist/types/src/components/Form/FormField/presentation.d.ts +22 -0
  67. package/dist/types/src/components/Form/FormField/presentation.d.ts.map +1 -0
  68. package/dist/types/src/components/Form/FormFieldSet/FormFieldSet.d.ts +8 -5
  69. package/dist/types/src/components/Form/FormFieldSet/FormFieldSet.d.ts.map +1 -1
  70. package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts +22 -0
  71. package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts.map +1 -0
  72. package/dist/types/src/components/Form/FormFieldSet/index.d.ts +1 -0
  73. package/dist/types/src/components/Form/FormFieldSet/index.d.ts.map +1 -1
  74. package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts +2 -0
  75. package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts.map +1 -1
  76. package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts +5 -5
  77. package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts.map +1 -1
  78. package/dist/types/src/components/Form/FormLayout/index.d.ts +1 -1
  79. package/dist/types/src/components/Form/FormLayout/index.d.ts.map +1 -1
  80. package/dist/types/src/components/Form/meta-tags.d.ts +1 -12
  81. package/dist/types/src/components/Form/meta-tags.d.ts.map +1 -1
  82. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  83. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +2 -1
  84. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -1
  85. package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts +2 -2
  86. package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts.map +1 -1
  87. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  88. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +2 -2
  89. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  90. package/dist/types/src/components/index.d.ts +0 -1
  91. package/dist/types/src/components/index.d.ts.map +1 -1
  92. package/dist/types/src/hooks/index.d.ts +1 -0
  93. package/dist/types/src/hooks/index.d.ts.map +1 -1
  94. package/dist/types/src/hooks/useAsyncFieldEffect.d.ts +24 -0
  95. package/dist/types/src/hooks/useAsyncFieldEffect.d.ts.map +1 -0
  96. package/dist/types/src/hooks/useFormContext.d.ts +8 -3
  97. package/dist/types/src/hooks/useFormContext.d.ts.map +1 -1
  98. package/dist/types/src/hooks/useFormHandler.d.ts +8 -2
  99. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  100. package/dist/types/src/index.d.ts +1 -0
  101. package/dist/types/src/index.d.ts.map +1 -1
  102. package/dist/types/src/testing/TestLayout.d.ts +1 -1
  103. package/dist/types/src/testing/TestLayout.d.ts.map +1 -1
  104. package/dist/types/src/testing/schema.d.ts +13 -16
  105. package/dist/types/src/testing/schema.d.ts.map +1 -1
  106. package/dist/types/src/types.d.ts +50 -1
  107. package/dist/types/src/types.d.ts.map +1 -1
  108. package/dist/types/src/util/index.d.ts +0 -1
  109. package/dist/types/src/util/index.d.ts.map +1 -1
  110. package/dist/types/src/util/omit.d.ts +1 -1
  111. package/dist/types/src/util/omit.d.ts.map +1 -1
  112. package/dist/types/tsconfig.tsbuildinfo +1 -1
  113. package/package.json +41 -40
  114. package/src/annotations.ts +63 -0
  115. package/src/components/FieldEditor/FieldEditor.tsx +5 -2
  116. package/src/components/Form/DESIGN.md +24 -0
  117. package/src/components/Form/Form.stories.tsx +257 -13
  118. package/src/components/Form/Form.theme.test.ts +33 -0
  119. package/src/components/Form/Form.theme.ts +86 -0
  120. package/src/components/Form/Form.tsx +15 -9
  121. package/src/components/Form/FormControls.tsx +94 -37
  122. package/src/components/Form/FormField/FormField.tsx +83 -32
  123. package/src/components/Form/FormField/FormFieldHeader.tsx +68 -0
  124. package/src/components/Form/FormField/{FormFieldWrapper.stories.tsx → FormRow.stories.tsx} +34 -9
  125. package/src/components/Form/FormField/FormRow.tsx +298 -0
  126. package/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.tsx +25 -9
  127. package/src/components/Form/FormField/fields/ArrayField/ArrayField.tsx +83 -24
  128. package/src/components/Form/FormField/fields/AsyncSelectField/AsyncSelectField.tsx +59 -0
  129. package/src/components/Form/FormField/fields/AsyncSelectField/index.ts +5 -0
  130. package/src/components/Form/FormField/fields/AutofillField/AutofillField.tsx +67 -0
  131. package/src/components/Form/FormField/fields/AutofillField/index.ts +5 -0
  132. package/src/components/Form/FormField/fields/BooleanField/BooleanField.tsx +5 -6
  133. package/src/components/Form/FormField/fields/ComboboxField/ComboboxField.tsx +104 -0
  134. package/src/components/Form/FormField/fields/ComboboxField/index.ts +5 -0
  135. package/src/components/Form/FormField/fields/DateField/DateField.tsx +9 -9
  136. package/src/components/Form/FormField/fields/GeoPointField/GeoPointField.tsx +17 -20
  137. package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.tsx +2 -2
  138. package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.tsx +39 -40
  139. package/src/components/Form/FormField/fields/NumberField/NumberField.test.ts +29 -0
  140. package/src/components/Form/FormField/fields/NumberField/NumberField.tsx +60 -21
  141. package/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.tsx +53 -0
  142. package/src/components/Form/FormField/fields/PasswordField/PasswordField.tsx +41 -0
  143. package/src/components/Form/FormField/fields/PasswordField/index.ts +5 -0
  144. package/src/components/Form/FormField/fields/RefField/InlineRefField.tsx +16 -10
  145. package/src/components/Form/FormField/fields/RefField/RefField.test.ts +8 -0
  146. package/src/components/Form/FormField/fields/RefField/RefField.tsx +50 -23
  147. package/src/components/Form/FormField/fields/SelectField/SelectField.tsx +104 -47
  148. package/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.tsx +3 -3
  149. package/src/components/Form/FormField/fields/TextAreaField/TextAreaField.tsx +3 -3
  150. package/src/components/Form/FormField/fields/TextField/TextField.tsx +3 -3
  151. package/src/components/Form/FormField/fields/TupleField/TupleField.tsx +1 -1
  152. package/src/components/Form/FormField/fields/index.ts +4 -0
  153. package/src/components/Form/FormField/index.ts +5 -3
  154. package/src/components/Form/FormField/presentation.tsx +32 -0
  155. package/src/components/Form/FormFieldSet/FormFieldSet.tsx +49 -70
  156. package/src/components/Form/FormFieldSet/FormFieldSetContainer.tsx +93 -0
  157. package/src/components/Form/FormFieldSet/index.ts +1 -0
  158. package/src/components/Form/FormLayout/FormLayout.stories.tsx +7 -5
  159. package/src/components/Form/FormLayout/FormLayout.tsx +24 -14
  160. package/src/components/Form/FormLayout/index.ts +1 -1
  161. package/src/components/Form/meta-tags.test.ts +1 -1
  162. package/src/components/ObjectForm/ObjectForm.tsx +2 -2
  163. package/src/components/ObjectPicker/ObjectPicker.tsx +1 -0
  164. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +1 -1
  165. package/src/components/ObjectProperties/ObjectProperties.tsx +12 -8
  166. package/src/components/RefEditor/RefEditor.stories.tsx +2 -2
  167. package/src/components/RefEditor/RefEditor.tsx +1 -1
  168. package/src/components/ViewEditor/ViewEditor.stories.tsx +2 -2
  169. package/src/components/ViewEditor/ViewEditor.test.tsx +8 -2
  170. package/src/components/ViewEditor/ViewEditor.tsx +3 -3
  171. package/src/components/index.ts +0 -1
  172. package/src/hooks/index.ts +1 -0
  173. package/src/hooks/useAsyncFieldEffect.ts +81 -0
  174. package/src/hooks/useFormContext.ts +6 -3
  175. package/src/hooks/useFormHandler.ts +11 -6
  176. package/src/index.ts +1 -0
  177. package/src/testing/TestLayout.tsx +15 -12
  178. package/src/testing/schema.ts +43 -43
  179. package/src/types.ts +53 -1
  180. package/src/util/index.ts +0 -1
  181. package/src/util/omit.ts +25 -11
  182. package/src/util/properties.test.ts +110 -28
  183. package/src/util/refs.ts +1 -1
  184. package/dist/types/src/components/Form/FormField/FieldHeader.d.ts +0 -19
  185. package/dist/types/src/components/Form/FormField/FieldHeader.d.ts.map +0 -1
  186. package/dist/types/src/components/Form/FormField/FormFieldWrapper.d.ts +0 -47
  187. package/dist/types/src/components/Form/FormField/FormFieldWrapper.d.ts.map +0 -1
  188. package/dist/types/src/components/Form/FormField/FormFieldWrapper.stories.d.ts.map +0 -1
  189. package/dist/types/src/components/Settings/Settings.d.ts +0 -51
  190. package/dist/types/src/components/Settings/Settings.d.ts.map +0 -1
  191. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +0 -1
  192. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +0 -43
  193. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +0 -1
  194. package/dist/types/src/components/Settings/SettingsItem.d.ts +0 -16
  195. package/dist/types/src/components/Settings/SettingsItem.d.ts.map +0 -1
  196. package/dist/types/src/components/Settings/SettingsSelectField.d.ts +0 -18
  197. package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +0 -1
  198. package/dist/types/src/components/Settings/index.d.ts +0 -4
  199. package/dist/types/src/components/Settings/index.d.ts.map +0 -1
  200. package/dist/types/src/util/field-type.d.ts +0 -19
  201. package/dist/types/src/util/field-type.d.ts.map +0 -1
  202. package/src/components/Form/FormField/FieldHeader.tsx +0 -36
  203. package/src/components/Form/FormField/FormFieldWrapper.tsx +0 -179
  204. package/src/components/Settings/AUDIT.md +0 -51
  205. package/src/components/Settings/Settings.stories.tsx +0 -104
  206. package/src/components/Settings/Settings.tsx +0 -81
  207. package/src/components/Settings/SettingsFieldSet.tsx +0 -257
  208. package/src/components/Settings/SettingsItem.tsx +0 -58
  209. package/src/components/Settings/SettingsSelectField.tsx +0 -59
  210. package/src/components/Settings/index.ts +0 -7
  211. package/src/util/field-type.ts +0 -60
@@ -0,0 +1,68 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import React, { type ReactNode } from 'react';
6
+
7
+ import { CompactIconButton } from './FormField';
8
+ import { FormFieldLabel } from './FormRow';
9
+
10
+ export type FormFieldHeaderProps = {
11
+ label: string;
12
+ /** JSON path forwarded to the label as field metadata. */
13
+ path?: string;
14
+ required?: boolean;
15
+ readonly?: boolean;
16
+ classNames?: string;
17
+ /** Trailing inline add affordance; omit to hide it. */
18
+ add?: { icon?: string; label: string; disabled?: boolean; onClick: () => void };
19
+ /** Extra trailing controls placed after the add affordance (e.g. a collapse toggle). */
20
+ actions?: ReactNode;
21
+ /** Header row click (e.g. toggle collapse). The add affordance stops propagation so it doesn't trigger this. */
22
+ onClick?: () => void;
23
+ };
24
+
25
+ /**
26
+ * Header row for a labelled group or list: a label with optional trailing controls (an inline add
27
+ * affordance and/or arbitrary `actions`). Shared by array/list fields ({@link ArrayField}, the view
28
+ * editor) and {@link FormFieldSetContainer}'s collapse header, so all group/list headers render identically.
29
+ */
30
+ export const FormFieldHeader = ({
31
+ label,
32
+ path,
33
+ required,
34
+ readonly,
35
+ classNames,
36
+ add,
37
+ actions,
38
+ onClick,
39
+ }: FormFieldHeaderProps) => (
40
+ <FormFieldLabel
41
+ standalone
42
+ classNames={classNames}
43
+ label={label}
44
+ required={required}
45
+ readonly={readonly}
46
+ path={path}
47
+ button={
48
+ (!readonly && add) || actions ? (
49
+ <>
50
+ {!readonly && add && (
51
+ <CompactIconButton
52
+ disabled={add.disabled}
53
+ icon={add.icon ?? 'ph--plus--regular'}
54
+ label={add.label}
55
+ onClick={(event) => {
56
+ // Don't let the add click bubble to the header's onClick (e.g. collapse toggle).
57
+ event.stopPropagation();
58
+ add.onClick();
59
+ }}
60
+ />
61
+ )}
62
+ {actions}
63
+ </>
64
+ ) : undefined
65
+ }
66
+ onClick={onClick}
67
+ />
68
+ );
@@ -11,7 +11,7 @@ import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
11
  import { type FormFieldRendererProps } from '#types';
12
12
 
13
13
  import { Form } from '../Form';
14
- import { FormFieldWrapper } from './FormFieldWrapper';
14
+ import { FormRow } from './FormRow';
15
15
 
16
16
  const DefaultStory = ({
17
17
  type,
@@ -30,7 +30,7 @@ const DefaultStory = ({
30
30
  <Form.Root>
31
31
  <Form.Viewport>
32
32
  <Form.Content>
33
- <FormFieldWrapper<string> {...props}>
33
+ <FormRow<string> {...props}>
34
34
  {({ value = '' }) => (
35
35
  <Input.TextInput
36
36
  disabled={!!readonly}
@@ -41,7 +41,7 @@ const DefaultStory = ({
41
41
  onBlur={onBlur}
42
42
  />
43
43
  )}
44
- </FormFieldWrapper>
44
+ </FormRow>
45
45
  </Form.Content>
46
46
  </Form.Viewport>
47
47
  </Form.Root>
@@ -49,7 +49,7 @@ const DefaultStory = ({
49
49
  };
50
50
 
51
51
  const meta: Meta<typeof DefaultStory> = {
52
- title: 'ui/react-ui-form/FormFieldWrapper',
52
+ title: 'ui/react-ui-form/FormRow',
53
53
  component: DefaultStory,
54
54
  decorators: [withTheme(), withLayout({ layout: 'column' })],
55
55
  parameters: {
@@ -61,12 +61,37 @@ export default meta;
61
61
 
62
62
  type Story = StoryObj<typeof meta>;
63
63
 
64
+ const props: Story['args'] = {
65
+ label: 'Label',
66
+ required: true,
67
+ getStatus: () => ({ status: 'error', error: 'Required field' }),
68
+ getValue: () => 'DXOS',
69
+ };
70
+
64
71
  export const Default: Story = {
65
72
  args: {
66
- label: 'Label',
67
- getStatus: () => ({ status: 'error', error: 'Required field' }),
68
- getValue: () => 'DXOS',
69
- onBlur: () => {},
70
- onValueChange: () => {},
73
+ ...props,
74
+ presentation: 'full',
75
+ },
76
+ };
77
+
78
+ export const Compact: Story = {
79
+ args: {
80
+ ...props,
81
+ presentation: 'compact',
82
+ },
83
+ };
84
+
85
+ export const Inline: Story = {
86
+ args: {
87
+ ...props,
88
+ presentation: 'inline',
89
+ },
90
+ };
91
+
92
+ export const Static: Story = {
93
+ args: {
94
+ ...props,
95
+ presentation: 'static',
71
96
  },
72
97
  };
@@ -0,0 +1,298 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { format as formatDate } from 'date-fns';
6
+ import React, { Component, type PropsWithChildren, type ReactNode, type Ref } from 'react';
7
+
8
+ import { Format } from '@dxos/echo';
9
+ import { Icon, Input, type ThemedClassName, Tooltip } from '@dxos/react-ui';
10
+ import { mx } from '@dxos/ui-theme';
11
+
12
+ import { type FormFieldRendererProps } from '#types';
13
+
14
+ import { useFormContext } from '../../../hooks';
15
+ import { type FormVariant, formTheme } from '../Form.theme';
16
+ import { type FormFieldPresentation, presentationFor } from './presentation';
17
+
18
+ //
19
+ // FormFieldLabel
20
+ //
21
+
22
+ export type FormFieldLabelProps = ThemedClassName<
23
+ {
24
+ /** Render a plain `<span>` instead of an input-associated `Input.Label`, for labels used outside an `Input.Root` (e.g. section/group headers). */
25
+ standalone?: boolean;
26
+ /** Class applied to the inner label text node, overriding the default size/color (e.g. `text-lg`). */
27
+ labelClassName?: string;
28
+ /** Form variant; selects the label's chrome (e.g. `settings` enlarges the text and places it in the field grid). */
29
+ variant?: FormVariant;
30
+ error?: string;
31
+ /**
32
+ * JSON path of the field this label describes (e.g. `runtime.client.storage.persistent`).
33
+ * Field metadata; accepted by callers but not currently rendered.
34
+ */
35
+ path?: string;
36
+ /**
37
+ * Trailing button rendered at the end of the label row (third grid column).
38
+ * Used by nested-object field sets to surface a collapse toggle.
39
+ */
40
+ button?: ReactNode;
41
+ onClick?: () => void;
42
+ } & Pick<FormFieldRendererProps, 'label' | 'readonly' | 'required'>
43
+ >;
44
+
45
+ export const FormFieldLabel = ({
46
+ classNames,
47
+ labelClassName,
48
+ variant = 'default',
49
+ label,
50
+ error,
51
+ readonly,
52
+ required,
53
+ standalone,
54
+ button,
55
+ onClick,
56
+ }: FormFieldLabelProps) => {
57
+ const styles = formTheme.styles({ variant });
58
+ // Render the required asterisk via a `::after` pseudo-element rather than a DOM node: it keeps the
59
+ // label's `textContent` exactly `label`, so fields stay locatable by their exact label text
60
+ // (`getByLabelText('Name')`), which the DOM-text-based query would otherwise miss as `Name *`.
61
+ // The `fieldLabelText` slot is applied last so a variant/caller size/color (e.g. `text-lg`) wins over
62
+ const labelClassNames = mx(
63
+ 'text-sm text-description',
64
+ required && "after:content-['*'] after:ms-0.5 after:text-warning-text",
65
+ styles.fieldLabelText({ class: labelClassName }),
66
+ );
67
+
68
+ // `Input.Label` is a themed primitive that reads `classNames` (and ignores `className`), whereas the
69
+ // plain `span` used for read-only/standalone labels reads `className`.
70
+ const labelNode =
71
+ readonly || standalone ? (
72
+ <span className={labelClassNames}>{label}</span>
73
+ ) : (
74
+ <Input.Label classNames={labelClassNames}>{label}</Input.Label>
75
+ );
76
+
77
+ return (
78
+ <div className={styles.fieldLabel({ class: mx(onClick && 'cursor-pointer', classNames) })} onClick={onClick}>
79
+ {labelNode}
80
+ {error ? (
81
+ <Tooltip.Trigger asChild content={error} side='bottom'>
82
+ <Icon icon='ph--warning--regular' size={4} classNames='text-error-text' />
83
+ </Tooltip.Trigger>
84
+ ) : (
85
+ <span />
86
+ )}
87
+ {button}
88
+ </div>
89
+ );
90
+ };
91
+
92
+ FormFieldLabel.displayName = 'Form.FieldLabel';
93
+
94
+ //
95
+ // FormRow
96
+ //
97
+
98
+ /**
99
+ * Formats a value for `static` (read-only, plain-DOM) presentation based on its
100
+ * type `format`. Dates/times are rendered human-readable; everything else falls
101
+ * back to `String(value)`.
102
+ */
103
+ const formatStaticValue = (value: unknown, format?: Format.TypeFormat): string => {
104
+ if (value == null) {
105
+ return '';
106
+ }
107
+
108
+ switch (format) {
109
+ case Format.TypeFormat.DateTime:
110
+ case Format.TypeFormat.Date:
111
+ case Format.TypeFormat.Time: {
112
+ const date = new Date(value as string);
113
+ if (Number.isNaN(date.getTime())) {
114
+ return String(value);
115
+ }
116
+ const pattern = format === Format.TypeFormat.DateTime ? 'PPp' : format === Format.TypeFormat.Date ? 'PP' : 'p';
117
+ return formatDate(date, pattern);
118
+ }
119
+ default:
120
+ return String(value);
121
+ }
122
+ };
123
+
124
+ const FORM_ROW_NAME = 'Form.Row';
125
+
126
+ export type FormRowProps<T = any> = ThemedClassName<
127
+ Pick<FormFieldRendererProps<T>, 'readonly' | 'label' | 'description' | 'presentation' | 'required'> &
128
+ Partial<Pick<FormFieldRendererProps<T>, 'getStatus' | 'getValue' | 'jsonPath' | 'format'>> & {
129
+ /**
130
+ * Ref to the row's outer element. Enables measurement/positioning (e.g. a future virtualized
131
+ * field set) without forwarding through the generic component.
132
+ */
133
+ rootRef?: Ref<HTMLDivElement>;
134
+ /**
135
+ * Render the label as a standalone `<span>` (group/multi-input fields with no single associated control).
136
+ */
137
+ standalone?: boolean;
138
+ /**
139
+ * Override the read-only/`static` rendering of the value. Fields whose value is not plain text
140
+ * (refs, selects, markdown) supply this; the default formats scalars/dates via `formatStaticValue`.
141
+ * Return `null` to render nothing (e.g. an empty/unresolved value).
142
+ */
143
+ renderStatic?: (value: T | undefined) => ReactNode;
144
+ /**
145
+ * Validation/error content rendered in the validation slot. Supplied directly in action mode; in
146
+ * field mode it is derived from `getStatus`.
147
+ */
148
+ validation?: ReactNode;
149
+ /**
150
+ * The control. A render-prop binds to the form value (field mode: `Input.Root` validation, static
151
+ * rendering, value via `getValue`). Plain nodes render an arbitrary control with no value wiring
152
+ * (action mode, e.g. a button) — the labeled-card escape hatch for an arbitrary control.
153
+ *
154
+ * `value` is `T | undefined` because `getValue()` returns no value when the field is unset (optional
155
+ * schema properties, freshly-added array items); renderers default it (e.g. `{ value = '' }`).
156
+ */
157
+ children?: ReactNode | ((props: { value: T | undefined; presentation: FormFieldPresentation }) => ReactNode);
158
+ }
159
+ >;
160
+
161
+ /**
162
+ * A labeled card row — the single shell behind both schema fields and free-form "action" rows. Field
163
+ * renderers pass a render-prop `children` (field mode: bound to the form value, with validation and
164
+ * static rendering); consumers pass plain `children` for an arbitrary control (action mode), the
165
+ * affordance for an arbitrary labeled control (action rows, settings cards).
166
+ */
167
+ export const FormRow = <T,>({
168
+ classNames,
169
+ children,
170
+ readonly,
171
+ presentation,
172
+ label,
173
+ description,
174
+ jsonPath,
175
+ format,
176
+ required,
177
+ standalone,
178
+ renderStatic,
179
+ validation,
180
+ getStatus,
181
+ getValue,
182
+ rootRef,
183
+ }: FormRowProps<T>) => {
184
+ const { variant = 'default' } = useFormContext(FORM_ROW_NAME);
185
+ const styles = formTheme.styles({ variant });
186
+ const { showDescription } = formTheme.behavior[variant];
187
+ const resolved = presentationFor(presentation);
188
+
189
+ //
190
+ // Field mode: a render-prop control bound to the form value.
191
+ //
192
+ if (typeof children === 'function' && getStatus && getValue) {
193
+ const { status, error } = getStatus();
194
+ const value: T | undefined = getValue();
195
+ if (resolved.isStatic && value == null) {
196
+ return null;
197
+ }
198
+
199
+ const control = resolved.isStatic
200
+ ? (renderStatic?.(value) ?? <p className='truncate min-w-0'>{formatStaticValue(value, format)}</p>)
201
+ : children({ value, presentation: resolved });
202
+
203
+ return (
204
+ <Input.Root validationValence={status}>
205
+ <div className={styles.field({ class: classNames })} ref={rootRef}>
206
+ {resolved.showLabel && (
207
+ <FormFieldLabel
208
+ variant={variant}
209
+ error={error}
210
+ readonly={readonly}
211
+ required={required}
212
+ standalone={standalone}
213
+ label={label}
214
+ path={jsonPath}
215
+ />
216
+ )}
217
+ {showDescription && description && (
218
+ <Input.Description classNames={styles.fieldDescription()}>{description}</Input.Description>
219
+ )}
220
+ <div className={styles.fieldControl()}>{control}</div>
221
+ {resolved.showError && error && (
222
+ <div className={styles.fieldValidation()}>
223
+ <Input.DescriptionAndValidation>
224
+ <Input.Validation>{error}</Input.Validation>
225
+ </Input.DescriptionAndValidation>
226
+ </div>
227
+ )}
228
+ </div>
229
+ </Input.Root>
230
+ );
231
+ }
232
+
233
+ //
234
+ // Action mode: an arbitrary control with a standalone label; no value/`Input.Root` wiring.
235
+ //
236
+ return (
237
+ <div ref={rootRef} className={styles.field({ class: classNames })}>
238
+ {resolved.showLabel && label && (
239
+ <FormFieldLabel
240
+ variant={variant}
241
+ readonly={readonly}
242
+ required={required}
243
+ standalone
244
+ label={label}
245
+ path={jsonPath}
246
+ />
247
+ )}
248
+ {showDescription && description && <p className={styles.fieldDescription()}>{description}</p>}
249
+ <div className={styles.fieldControl()}>{typeof children === 'function' ? null : children}</div>
250
+ {validation && <div className={styles.fieldValidation()}>{validation}</div>}
251
+ </div>
252
+ );
253
+ };
254
+
255
+ FormRow.displayName = FORM_ROW_NAME;
256
+
257
+ //
258
+ // FormFieldErrorBoundary
259
+ //
260
+
261
+ type FormFieldErrorState = {
262
+ error: Error | undefined;
263
+ };
264
+
265
+ type FormFieldErrorBoundaryProps = PropsWithChildren<{
266
+ path?: (string | number)[];
267
+ }>;
268
+
269
+ export class FormFieldErrorBoundary extends Component<FormFieldErrorBoundaryProps, FormFieldErrorState> {
270
+ static getDerivedStateFromError(error: Error): { error: Error } {
271
+ return { error };
272
+ }
273
+
274
+ override state = { error: undefined };
275
+
276
+ override componentDidUpdate(prevProps: FormFieldErrorBoundaryProps) {
277
+ if (prevProps.path !== this.props.path) {
278
+ this.resetError();
279
+ }
280
+ }
281
+
282
+ override render() {
283
+ if (this.state.error) {
284
+ return (
285
+ <div className='flex gap-2 border border-error-border font-mono text-sm'>
286
+ <span className='bg-error-bg text-base-fg px-1 font-thin'>ERROR</span>
287
+ {String(this.props.path?.join('.'))}
288
+ </div>
289
+ );
290
+ }
291
+
292
+ return this.props.children;
293
+ }
294
+
295
+ private resetError() {
296
+ this.setState({ error: undefined });
297
+ }
298
+ }
@@ -47,29 +47,41 @@ const Pipeline = Schema.Struct({
47
47
  columns: columnsField,
48
48
  }).pipe(Schema.mutable);
49
49
 
50
- // `FormOrderedAnnotation` opts the array into a drag-to-reorder list (see `ArrayField`).
51
50
  const OrderedPipeline = Schema.Struct({
52
51
  ...headerFields,
53
52
  columns: columnsField.pipe(FormOrderedAnnotation.set(true)),
54
53
  }).pipe(Schema.mutable);
55
54
 
56
- type Pipeline = Schema.Schema.Type<typeof Pipeline>;
55
+ const StringPipeline = Schema.Struct({
56
+ ...headerFields,
57
+ columns: Schema.Array(Schema.String).pipe(FormOrderedAnnotation.set(true)),
58
+ }).pipe(Schema.mutable);
59
+
60
+ type PipelineValues = { name?: string; description?: string; columns: readonly unknown[] };
57
61
 
58
- const initialValues: Pipeline = {
62
+ // Object-array columns (matches `Pipeline` / `OrderedPipeline`).
63
+ const objectColumns: PipelineValues = {
59
64
  name: 'Sales',
60
65
  description: 'Lead qualification pipeline.',
61
66
  columns: [{ name: 'Contacts' }, { name: 'Organizations' }, { name: 'Tasks' }, { name: 'Messages', value: 100 }],
62
67
  };
63
68
 
64
- const StoryComponent = ({ schema }: { schema: typeof Pipeline }) => {
65
- const [values, setValues] = useState<Pipeline>(initialValues);
69
+ // String-array columns (matches `StringPipeline`); feeding object columns here renders `[object Object]`.
70
+ const stringColumns: PipelineValues = {
71
+ name: 'Sales',
72
+ description: 'Lead qualification pipeline.',
73
+ columns: ['Contacts', 'Organizations', 'Tasks', 'Messages'],
74
+ };
75
+
76
+ const DefaultStory = ({ schema, values: initial }: { schema: Schema.Schema<any>; values: PipelineValues }) => {
77
+ const [values, setValues] = useState<PipelineValues>(initial);
66
78
 
67
79
  return (
68
80
  <TestLayout json={values}>
69
81
  <Form.Root
70
82
  schema={schema}
71
83
  values={values}
72
- onValuesChanged={(values) => setValues((prev) => ({ ...prev, ...values }))}
84
+ onValuesChanged={(next) => setValues((prev) => ({ ...prev, ...next }))}
73
85
  >
74
86
  <Form.Viewport scroll>
75
87
  <Form.Content>
@@ -88,16 +100,20 @@ const meta = {
88
100
  layout: 'fullscreen',
89
101
  translations,
90
102
  },
91
- } satisfies Meta<typeof StoryComponent>;
103
+ } satisfies Meta<typeof DefaultStory>;
92
104
 
93
105
  export default meta;
94
106
 
95
107
  type Story = StoryObj<typeof meta>;
96
108
 
97
109
  export const Default: Story = {
98
- render: () => <StoryComponent schema={Pipeline} />,
110
+ render: () => <DefaultStory schema={Pipeline} values={objectColumns} />,
99
111
  };
100
112
 
101
113
  export const Ordered: Story = {
102
- render: () => <StoryComponent schema={OrderedPipeline} />,
114
+ render: () => <DefaultStory schema={OrderedPipeline} values={objectColumns} />,
115
+ };
116
+
117
+ export const Simple: Story = {
118
+ render: () => <DefaultStory schema={StringPipeline} values={stringColumns} />,
103
119
  };
@@ -7,9 +7,13 @@ import * as SchemaAST from 'effect/SchemaAST';
7
7
  import React, { type ReactNode, useCallback, useRef } from 'react';
8
8
 
9
9
  import { Annotation, Ref } from '@dxos/echo';
10
+ import { useType as defaultUseType } from '@dxos/echo-react';
10
11
  import { SchemaEx } from '@dxos/effect';
12
+ import { DXN } from '@dxos/keys';
13
+ import { log } from '@dxos/log';
11
14
  import { useTranslation } from '@dxos/react-ui';
12
15
  import { OrderedList } from '@dxos/react-ui-list';
16
+ import { mx } from '@dxos/ui-theme';
13
17
  import { arrayMove } from '@dxos/util';
14
18
 
15
19
  import { translationKey } from '#translations';
@@ -17,8 +21,8 @@ import { type FormFieldStateProps } from '#types';
17
21
 
18
22
  import { useFormValues } from '../../../../../hooks';
19
23
  import { getFormProperties } from '../../../../../util';
20
- import { FieldHeader } from '../../FieldHeader';
21
24
  import { CompactIconButton, FormField, type FormFieldProps } from '../../FormField';
25
+ import { FormFieldHeader } from '../../FormFieldHeader';
22
26
 
23
27
  // Synthetic id assigned to each row when rendering an ordered list. Plain form
24
28
  // values have no stable identity, so drag-and-drop (which requires a stable key
@@ -59,6 +63,14 @@ export const ArrayField = ({
59
63
  // Order is meaningful and user-controllable -> render as a drag-to-reorder list.
60
64
  const ordered = Annotation.FormOrderedAnnotation.getFromAst(type).pipe(Option.getOrElse(() => false));
61
65
 
66
+ // Owned-ref array (`FormCreateAnnotation`): "add" creates a new target object of the annotation's typename,
67
+ // and each row renders that target inline (no existing-object picker).
68
+ const { db, onCreate, getCreateDefaults, useType = defaultUseType } = props;
69
+ const createTypename = Annotation.FormCreateAnnotation.getFromAst(type).pipe(Option.getOrUndefined);
70
+ const createSchema = useType(db, createTypename ? DXN.make(createTypename) : undefined);
71
+ const createInline =
72
+ !!createTypename && !!elementType && Ref.isRefType(elementType) && !readonly && layout !== 'static';
73
+
62
74
  // Synthetic identity map (see `DND_ID`). The counter only advances when the
63
75
  // array grows; reconciliation here covers the initial mount and any external
64
76
  // length change, while the handlers below keep ids aligned on mutation.
@@ -66,6 +78,21 @@ export const ArrayField = ({
66
78
  const counterRef = useRef(0);
67
79
  const nextId = useCallback(() => `${DND_ID}-${counterRef.current++}`, []);
68
80
 
81
+ // Create a new owned target object and append its ref (see `createInline`).
82
+ const handleAddCreate = useCallback(async () => {
83
+ if (!createSchema || !onCreate) {
84
+ return;
85
+ }
86
+ // Let the container pre-populate the new object (e.g. a back-reference to the parent).
87
+ const defaults = getCreateDefaults?.({ jsonPath: SchemaEx.createJsonPath(path ?? []), schema: createSchema }) ?? {};
88
+ const created = await onCreate(createSchema, defaults);
89
+ if (!created) {
90
+ return;
91
+ }
92
+ idsRef.current.push(nextId());
93
+ onValueChange(type, [...(values ?? []), Ref.make(created)]);
94
+ }, [createSchema, onCreate, getCreateDefaults, onValueChange, type, path, values, nextId]);
95
+
69
96
  const getDefaultObjectValue = (typeNode: SchemaAST.AST): any => {
70
97
  const baseNode = SchemaEx.findNode(typeNode, SchemaEx.isDiscriminatedUnion);
71
98
  const typeLiteral = baseNode
@@ -86,10 +113,18 @@ export const ArrayField = ({
86
113
  };
87
114
 
88
115
  const handleAdd = useCallback(() => {
89
- const defaultValue =
90
- elementType && SchemaEx.isNestedType(elementType)
91
- ? getDefaultObjectValue(elementType)
92
- : getDefaultValue(elementType);
116
+ let defaultValue;
117
+ try {
118
+ defaultValue =
119
+ elementType && SchemaEx.isNestedType(elementType)
120
+ ? getDefaultObjectValue(elementType)
121
+ : getDefaultValue(elementType);
122
+ } catch (err) {
123
+ // `getDefaultValue` throws on element types it can't seed (runs in this click handler, so an
124
+ // unguarded throw would be uncaught). Skip the add rather than crash the form.
125
+ log.catch(err);
126
+ return;
127
+ }
93
128
  idsRef.current.push(nextId());
94
129
  // `values` is `undefined` on first render for arrays whose parent path
95
130
  // hasn't been materialised in the form values yet (e.g. `package.repos`
@@ -129,28 +164,46 @@ export const ArrayField = ({
129
164
 
130
165
  // Object items: a recursively-rendered FormField (multiple sub-rows for the
131
166
  // object's fields). Scalar items (refs, primitives): a single inline FormField.
132
- const renderField = (index: number, isLast: boolean): ReactNode => (
133
- <FormField
134
- {...props}
135
- autoFocus={isLast}
136
- type={elementType}
137
- // Suppress the per-item header for object items only — the recursive
138
- // form already renders labels for each sub-field. Scalar items
139
- // (refs, primitives) keep the parent name so inline-layout children
140
- // (e.g. RefField) have a real label to use as a fallback placeholder.
141
- {...(renderItemAsObject && { name: null })}
142
- path={[...(path ?? []), index]}
143
- readonly={readonly || layout === 'static'}
144
- layout={renderItemAsObject ? (layout === 'static' ? 'static' : undefined) : 'inline'}
145
- />
146
- );
167
+ const renderField = (index: number, isLast: boolean): ReactNode => {
168
+ const field = (
169
+ <FormField
170
+ {...props}
171
+ autoFocus={isLast}
172
+ type={elementType}
173
+ // Suppress the per-item header for object items and owned-ref items (both render their own
174
+ // sub-field labels). Scalar items (refs, primitives) take the array's resolved label (e.g. its
175
+ // `title` annotation, `Tags`) so inline-layout children (e.g. RefField) have a real label to use
176
+ // as a fallback placeholder, rather than re-deriving it from the raw array property name (`_tags`).
177
+ {...(renderItemAsObject || createInline ? { name: null } : { label })}
178
+ path={[...(path ?? []), index]}
179
+ readonly={readonly || layout === 'static'}
180
+ layout={renderItemAsObject ? (layout === 'static' ? 'static' : undefined) : 'inline'}
181
+ refInline={createInline || undefined}
182
+ />
183
+ );
184
+
185
+ // TODO(burdon): Hacky.
186
+ // An owned inline object form must establish its own column context: otherwise it inherits the parent
187
+ // form's `--dx-col` (center) placement and collapses into the array row's narrow second track.
188
+ return createInline ? (
189
+ <div role='none' className='w-full min-w-0 [--dx-col:auto]'>
190
+ {field}
191
+ </div>
192
+ ) : (
193
+ field
194
+ );
195
+ };
147
196
 
148
197
  const header = (layout !== 'static' || (values && values.length > 0)) && (
149
- <FieldHeader
198
+ <FormFieldHeader
150
199
  label={label}
151
200
  path={SchemaEx.createJsonPath(path ?? [])}
152
201
  readonly={readonly}
153
- add={layout !== 'static' ? { label: t('add-item.button'), onClick: handleAdd } : undefined}
202
+ add={
203
+ layout !== 'static'
204
+ ? { label: t('add-item.button'), onClick: createInline ? () => void handleAddCreate() : handleAdd }
205
+ : undefined
206
+ }
154
207
  />
155
208
  );
156
209
 
@@ -207,11 +260,17 @@ export const ArrayField = ({
207
260
  return (
208
261
  <>
209
262
  {header}
210
- <div className='flex flex-col'>
263
+ <div className='flex flex-col gap-2'>
211
264
  {values?.map((_, index) => {
212
265
  const isLast = index === values.length - 1;
213
266
  return (
214
- <div key={index} className='grid grid-cols-[1fr_min-content] items-center mb-1 last:mb-form-gap'>
267
+ <div
268
+ key={index}
269
+ className={mx(
270
+ 'grid grid-cols-[minmax(0,1fr)_min-content] gap-1',
271
+ renderItemAsObject || createInline ? 'items-start' : 'items-center',
272
+ )}
273
+ >
215
274
  {renderField(index, isLast)}
216
275
  {!readonly && layout !== 'static' && (
217
276
  <CompactIconButton