@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
@@ -0,0 +1,80 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import React from 'react';
6
+
7
+ import { invariant } from '@dxos/invariant';
8
+ import { Select } from '@dxos/react-ui';
9
+
10
+ import { type FormFieldRenderer, type FormFieldRendererProps } from '#types';
11
+
12
+ import { FormRow } from '../../FormRow';
13
+ import { type SelectFieldOption } from './SelectField';
14
+
15
+ // Kept out of `SelectField.tsx`: react-refresh only fast-refreshes a module whose
16
+ // exports are all components, so values exported beside them force a full page reload on every edit.
17
+
18
+ export type CreateSelectFieldOptions = {
19
+ /** Options to display. Strings are used as both value and label. */
20
+ options: ReadonlyArray<string | SelectFieldOption>;
21
+ /** Label for the sentinel option that maps to `undefined`. Defaults to `'Default'`. Pass `null` to omit. */
22
+ defaultLabel?: string | null;
23
+ };
24
+
25
+ /**
26
+ * Factory for a `fieldMap` entry (keyed by JSON path) that renders a Select over a fixed list of
27
+ * options, with an optional sentinel mapping to `undefined`. Use for fields whose choices come from a
28
+ * runtime list rather than the schema (e.g. available model ids).
29
+ */
30
+ export const createSelectField = ({
31
+ options,
32
+ defaultLabel = 'Default',
33
+ }: CreateSelectFieldOptions): FormFieldRenderer => {
34
+ const normalized = options.map((option) => (typeof option === 'string' ? { value: option, label: option } : option));
35
+ const hasDefault = defaultLabel !== null;
36
+ // The sentinel maps to `undefined`. Radix forbids an empty-string `Select.Item` value, so use a
37
+ // non-empty placeholder; reserved (a real option with this value would be unrepresentable).
38
+ const sentinel = '__default__';
39
+ invariant(
40
+ !normalized.some((option) => option.value === sentinel),
41
+ `createSelectField: option value '${sentinel}' is reserved.`,
42
+ );
43
+
44
+ return ({ type, readonly, onValueChange, ...props }: FormFieldRendererProps<string | undefined>) => (
45
+ <FormRow<string>
46
+ readonly={readonly}
47
+ renderStatic={(value) => (
48
+ <p className='truncate min-w-0'>
49
+ {normalized.find((option) => option.value === value)?.label ?? String(value ?? '')}
50
+ </p>
51
+ )}
52
+ {...props}
53
+ >
54
+ {({ value }) => (
55
+ <Select.Root
56
+ disabled={!!readonly}
57
+ value={value ?? sentinel}
58
+ onValueChange={(next) => onValueChange(type, hasDefault && next === sentinel ? undefined : next)}
59
+ >
60
+ <Select.TriggerButton classNames='w-full' disabled={!!readonly} />
61
+ {normalized.length > 0 && (
62
+ <Select.Portal>
63
+ <Select.Content>
64
+ <Select.Viewport>
65
+ {hasDefault && <Select.Option value={sentinel}>{defaultLabel}</Select.Option>}
66
+ {normalized.map((option) => (
67
+ <Select.Option key={option.value} value={option.value}>
68
+ {option.label ?? option.value}
69
+ </Select.Option>
70
+ ))}
71
+ </Select.Viewport>
72
+ <Select.Arrow />
73
+ </Select.Content>
74
+ </Select.Portal>
75
+ )}
76
+ </Select.Root>
77
+ )}
78
+ </FormRow>
79
+ );
80
+ };
@@ -2,4 +2,6 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ export * from './create-select-field';
6
+
5
7
  export * from './SelectField';
@@ -9,7 +9,8 @@ import { PublicKey } from '@dxos/keys';
9
9
  import { type ChromaticPalette, IconButton, Input, Tag, ToggleIconButton, useTranslation } from '@dxos/react-ui';
10
10
  import { OrderedList } from '@dxos/react-ui-list';
11
11
  import { HuePicker } from '@dxos/react-ui-pickers';
12
- import { hues, osTranslations } from '@dxos/ui-theme';
12
+ import { osTranslations } from '@dxos/ui-theme';
13
+ import { hues } from '@dxos/ui-types';
13
14
 
14
15
  import { translationKey } from '#translations';
15
16
  import { type FormFieldRendererProps } from '#types';
@@ -2,7 +2,7 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- import React, { type PropsWithChildren, useState } from 'react';
5
+ import React, { Children, type PropsWithChildren, useState } from 'react';
6
6
 
7
7
  import { type ThemedClassName, ToggleIconButton, useTranslation } from '@dxos/react-ui';
8
8
  import { mx } from '@dxos/ui-theme';
@@ -10,8 +10,7 @@ import { mx } from '@dxos/ui-theme';
10
10
  import { translationKey } from '#translations';
11
11
 
12
12
  import { formTheme } from '../Form.theme';
13
- import { FormFieldHeader } from '../FormField/FormFieldHeader';
14
- import { type FormFieldPresentation } from '../FormField/presentation';
13
+ import { FormFieldHeader, type FormFieldPresentation } from '../FormField';
15
14
 
16
15
  const styles = formTheme.styles();
17
16
 
@@ -47,7 +46,10 @@ export const FormFieldSetContainer = ({
47
46
  const { t } = useTranslation(translationKey);
48
47
  // TODO(burdon): Generalize collapse state (cf. useSelection in react-ui-attention, plugin-markdown cursor state).
49
48
  const [collapsed, setCollapsed] = useState(false);
50
- const showBody = !(collapsible && collapsed);
49
+ // An empty object/array renders no sub-fields, so there is nothing to collapse — offering the
50
+ // toggle (and the click target on the header) would be a control that does nothing.
51
+ const canCollapse = collapsible && Children.toArray(children).length > 0;
52
+ const showBody = !(canCollapse && collapsed);
51
53
 
52
54
  const content = (
53
55
  <>
@@ -58,29 +60,29 @@ export const FormFieldSetContainer = ({
58
60
  readonly={readonly}
59
61
  classNames='pl-2'
60
62
  actions={
61
- collapsible ? (
63
+ canCollapse ? (
62
64
  <ToggleIconButton
63
65
  active={!collapsed}
64
66
  classNames='px-1 mr-0.5'
65
67
  variant='ghost'
66
- density='xs'
68
+ density='sm'
67
69
  iconOnly
68
70
  icon='ph--caret-right--regular'
69
71
  label={t(collapsed ? 'expand-fields.label' : 'collapse-fields.label')}
70
72
  />
71
73
  ) : undefined
72
74
  }
73
- onClick={collapsible ? () => setCollapsed((value) => !value) : undefined}
75
+ onClick={canCollapse ? () => setCollapsed((value) => !value) : undefined}
74
76
  />
75
77
  )}
76
- {showBody && (collapsible ? <div className={styles.fieldSetBody()}>{children}</div> : children)}
78
+ {showBody && (canCollapse ? <div className={styles.fieldSetBody()}>{children}</div> : children)}
77
79
  </>
78
80
  );
79
81
 
80
82
  // Nested groups render inside an indented, bordered container with a collapse toggle. A non-collapsible
81
83
  // group only materializes a wrapper when `classNames` is supplied — otherwise the body flows straight
82
84
  // into the parent grid (the default, grid-transparent behavior).
83
- if (collapsible) {
85
+ if (canCollapse) {
84
86
  return (
85
87
  <div className={styles.fieldSetBoxOuter()}>
86
88
  <div className={styles.fieldSetBox({ class: mx(classNames) })}>{content}</div>
@@ -8,7 +8,7 @@ import { describe, test } from 'vitest';
8
8
 
9
9
  import { Annotation } from '@dxos/echo';
10
10
 
11
- import { resolveLayoutField } from './FormLayout';
11
+ import { resolveLayoutField } from './resolve-layout-field';
12
12
 
13
13
  const Place = Schema.Struct({
14
14
  name: Schema.optional(Schema.String),
@@ -4,7 +4,6 @@
4
4
 
5
5
  import * as Option from 'effect/Option';
6
6
  import * as Schema from 'effect/Schema';
7
- import * as SchemaAST from 'effect/SchemaAST';
8
7
  import * as String from 'effect/String';
9
8
  import React, { Fragment, useMemo } from 'react';
10
9
 
@@ -19,6 +18,7 @@ import { useFormContext, useFormFieldState } from '../../../hooks';
19
18
  import { formTheme } from '../Form.theme';
20
19
  import { FormField, FormFieldErrorBoundary, FormFieldLabel, type FormFieldProps, presentationFor } from '../FormField';
21
20
  import { type LayoutNode, LayoutParseError, parseLayout } from './parser';
21
+ import { resolveLayoutField } from './resolve-layout-field';
22
22
 
23
23
  const FORM_LAYOUT_NAME = 'Form.Layout';
24
24
 
@@ -137,91 +137,6 @@ const RenderNode = ({ node, schema, basePath, ...props }: RenderNodeProps) => {
137
137
  );
138
138
  };
139
139
 
140
- export type ResolvedLayoutField = {
141
- /** Resolved leaf AST node for the field. */
142
- type: SchemaAST.AST;
143
- /** Path segments (dotted names split on `.`). */
144
- segments: string[];
145
- /** Last path segment; used to derive the field label when there is no `title` annotation. */
146
- leafName: string;
147
- /** Resolved `title` annotation for the field, if any. */
148
- title?: string;
149
- /**
150
- * Set when the field resolves to a nested struct carrying a `LabelAnnotation` —
151
- * the type literal whose computed label should render in place of the struct's
152
- * sub-fields. Undefined for ordinary (non-auto-labelled) fields.
153
- */
154
- labelType?: SchemaAST.AST;
155
- /** Whether the resolved property is required (non-optional). */
156
- required: boolean;
157
- };
158
-
159
- /**
160
- * Walks a dotted path of property names through nested struct ASTs, returning the
161
- * leaf `PropertySignature` (or `undefined` if any segment is missing).
162
- */
163
- const resolvePropertySignature = (ast: SchemaAST.AST, segments: string[]): SchemaAST.PropertySignature | undefined => {
164
- let node: SchemaAST.AST = ast;
165
- for (let index = 0; index < segments.length; index++) {
166
- const typeLiteral = SchemaEx.findNode(node, SchemaAST.isTypeLiteral);
167
- if (!typeLiteral) {
168
- return undefined;
169
- }
170
- const prop = SchemaAST.getPropertySignatures(typeLiteral).find(
171
- (p) => globalThis.String(p.name) === segments[index],
172
- );
173
- if (!prop) {
174
- return undefined;
175
- }
176
- if (index === segments.length - 1) {
177
- return prop;
178
- }
179
- node = prop.type;
180
- }
181
-
182
- return undefined;
183
- };
184
-
185
- /**
186
- * Resolves a (possibly dotted) layout field name against a schema.
187
- * - `origin.code` drills through nested structs to the leaf field.
188
- * - A name resolving to a nested struct with a `LabelAnnotation` is flagged via
189
- * `labelType` so the layout can auto-convert it to a single read-only label.
190
- * Returns `undefined` when the name does not resolve to a property.
191
- */
192
- export const resolveLayoutField = (schema: Schema.Schema<any>, name: string): ResolvedLayoutField | undefined => {
193
- const segments = name.split('.');
194
- const prop = resolvePropertySignature(schema.ast, segments);
195
- if (!prop) {
196
- return undefined;
197
- }
198
-
199
- // Normalized leaf type (optional unwrapped, refinements stripped, signature-level
200
- // annotations merged) — matches how `getProperties` feeds `FormField`.
201
- const { type: baseType } = SchemaEx.getBaseType(prop);
202
- const type =
203
- prop.annotations && Reflect.ownKeys(prop.annotations).length > 0
204
- ? ({ ...baseType, annotations: { ...baseType.annotations, ...prop.annotations } } as SchemaAST.AST)
205
- : baseType;
206
-
207
- const title = SchemaEx.getAnnotation<string>(SchemaAST.TitleAnnotationId)(type);
208
-
209
- // Label detection reads the *raw* property type: `SchemaEx.getBaseType`'s `encodedBoundAST`
210
- // strips annotations from non-keyword inner types (e.g. nested structs), which would
211
- // drop the `LabelAnnotation` we rely on here.
212
- const labelType = SchemaEx.findNode(prop.type, SchemaAST.isTypeLiteral);
213
- const labelled = labelType != null && Option.isSome(Annotation.LabelAnnotation.getFromAst(labelType));
214
-
215
- return {
216
- type,
217
- segments,
218
- leafName: segments[segments.length - 1],
219
- title,
220
- labelType: labelled ? labelType : undefined,
221
- required: !prop.isOptional,
222
- };
223
- };
224
-
225
140
  type LabelFieldProps = {
226
141
  schema: Schema.Schema<any>;
227
142
  label: string;
@@ -2,5 +2,7 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
+ export { type ResolvedLayoutField, resolveLayoutField } from './resolve-layout-field';
6
+
5
7
  export { FormLayout, type FormLayoutProps } from './FormLayout';
6
8
  export { type LayoutNode, LayoutParseError, parseLayout } from './parser';
@@ -0,0 +1,98 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Option from 'effect/Option';
6
+ import * as Schema from 'effect/Schema';
7
+ import * as SchemaAST from 'effect/SchemaAST';
8
+
9
+ import { Annotation } from '@dxos/echo';
10
+ import { SchemaEx } from '@dxos/effect';
11
+
12
+ // Kept out of `FormLayout.tsx`: react-refresh only fast-refreshes a module whose
13
+ // exports are all components, so values exported beside them force a full page reload on every edit.
14
+
15
+ export type ResolvedLayoutField = {
16
+ /** Resolved leaf AST node for the field. */
17
+ type: SchemaAST.AST;
18
+ /** Path segments (dotted names split on `.`). */
19
+ segments: string[];
20
+ /** Last path segment; used to derive the field label when there is no `title` annotation. */
21
+ leafName: string;
22
+ /** Resolved `title` annotation for the field, if any. */
23
+ title?: string;
24
+ /**
25
+ * Set when the field resolves to a nested struct carrying a `LabelAnnotation` —
26
+ * the type literal whose computed label should render in place of the struct's
27
+ * sub-fields. Undefined for ordinary (non-auto-labelled) fields.
28
+ */
29
+ labelType?: SchemaAST.AST;
30
+ /** Whether the resolved property is required (non-optional). */
31
+ required: boolean;
32
+ };
33
+
34
+ /**
35
+ * Walks a dotted path of property names through nested struct ASTs, returning the
36
+ * leaf `PropertySignature` (or `undefined` if any segment is missing).
37
+ */
38
+ const resolvePropertySignature = (ast: SchemaAST.AST, segments: string[]): SchemaAST.PropertySignature | undefined => {
39
+ let node: SchemaAST.AST = ast;
40
+ for (let index = 0; index < segments.length; index++) {
41
+ const typeLiteral = SchemaEx.findNode(node, SchemaAST.isTypeLiteral);
42
+ if (!typeLiteral) {
43
+ return undefined;
44
+ }
45
+ const prop = SchemaAST.getPropertySignatures(typeLiteral).find(
46
+ (p) => globalThis.String(p.name) === segments[index],
47
+ );
48
+ if (!prop) {
49
+ return undefined;
50
+ }
51
+ if (index === segments.length - 1) {
52
+ return prop;
53
+ }
54
+ node = prop.type;
55
+ }
56
+
57
+ return undefined;
58
+ };
59
+
60
+ /**
61
+ * Resolves a (possibly dotted) layout field name against a schema.
62
+ * - `origin.code` drills through nested structs to the leaf field.
63
+ * - A name resolving to a nested struct with a `LabelAnnotation` is flagged via
64
+ * `labelType` so the layout can auto-convert it to a single read-only label.
65
+ * Returns `undefined` when the name does not resolve to a property.
66
+ */
67
+ export const resolveLayoutField = (schema: Schema.Schema<any>, name: string): ResolvedLayoutField | undefined => {
68
+ const segments = name.split('.');
69
+ const prop = resolvePropertySignature(schema.ast, segments);
70
+ if (!prop) {
71
+ return undefined;
72
+ }
73
+
74
+ // Normalized leaf type (optional unwrapped, refinements stripped, signature-level
75
+ // annotations merged) — matches how `getProperties` feeds `FormField`.
76
+ const { type: baseType } = SchemaEx.getBaseType(prop);
77
+ const type =
78
+ prop.annotations && Reflect.ownKeys(prop.annotations).length > 0
79
+ ? ({ ...baseType, annotations: { ...baseType.annotations, ...prop.annotations } } as SchemaAST.AST)
80
+ : baseType;
81
+
82
+ const title = SchemaEx.getAnnotation<string>(SchemaAST.TitleAnnotationId)(type);
83
+
84
+ // Label detection reads the *raw* property type: `SchemaEx.getBaseType`'s `encodedBoundAST`
85
+ // strips annotations from non-keyword inner types (e.g. nested structs), which would
86
+ // drop the `LabelAnnotation` we rely on here.
87
+ const labelType = SchemaEx.findNode(prop.type, SchemaAST.isTypeLiteral);
88
+ const labelled = labelType != null && Option.isSome(Annotation.LabelAnnotation.getFromAst(labelType));
89
+
90
+ return {
91
+ type,
92
+ segments,
93
+ leafName: segments[segments.length - 1],
94
+ title,
95
+ labelType: labelled ? labelType : undefined,
96
+ required: !prop.isOptional,
97
+ };
98
+ };
@@ -3,18 +3,28 @@
3
3
  //
4
4
 
5
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
6
- import React from 'react';
6
+ import React, { useCallback, useEffect, useState } from 'react';
7
+ import { expect, waitFor, within } from 'storybook/test';
7
8
 
8
9
  import { Filter, Obj, Ref, Tag } from '@dxos/echo';
9
- import { useQuery } from '@dxos/react-client/echo';
10
+ import { useObject, useQuery } from '@dxos/echo-react';
11
+ import { random } from '@dxos/random';
10
12
  import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
13
+ import { Toolbar } from '@dxos/react-ui';
11
14
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
12
15
  import { Person } from '@dxos/types';
13
16
 
14
17
  import { translations } from '#translations';
15
18
 
19
+ import { TestLayout } from '../../testing';
16
20
  import { ObjectForm } from './ObjectForm';
17
21
 
22
+ // Seed so the first background mutation deterministically targets `fullName` (moving it off 'Alice Carroll'), which
23
+ // the play asserts. Seeded inside the component (below) rather than at module level: `@dxos/random` is a shared
24
+ // singleton, and the full storybook run imports every story up front and runs them in sequence, so a module-level
25
+ // seed gets consumed by other stories before this story's first tick.
26
+ const REACTIVE_SEED = 42;
27
+
18
28
  const DefaultStory = () => {
19
29
  const { space } = useClientStory();
20
30
  const [person] = useQuery(space?.db, Filter.type(Person.Person));
@@ -30,7 +40,7 @@ const meta = {
30
40
  render: DefaultStory,
31
41
  decorators: [
32
42
  withTheme(),
33
- withLayout({ layout: 'column', classNames: 'p-2', scroll: true }),
43
+ withLayout({ classNames: 'p-2', scroll: true }),
34
44
  withClientProvider({
35
45
  types: [Person.Person, Tag.Tag],
36
46
  createIdentity: true,
@@ -60,3 +70,83 @@ export default meta;
60
70
  type Story = StoryObj<typeof meta>;
61
71
 
62
72
  export const Default: Story = {};
73
+
74
+ /**
75
+ * Exercises the reactive companion form against a live ECHO object. The toolbar mutates the object's fields in the
76
+ * background (outside the form); because `ObjectForm` subscribes via `useObject`, those external changes appear in the
77
+ * inputs immediately. Editing a field holds your (possibly invalid) draft locally without snapping back, while the
78
+ * other fields keep updating from the background mutations.
79
+ */
80
+ const ReactiveStory = () => {
81
+ const { space } = useClientStory();
82
+ const [person] = useQuery(space?.db, Filter.type(Person.Person));
83
+ // Reactive snapshot for the JSON panel: reflects both background mutations and the form's own writes, so it shows
84
+ // whether a form edit reaches the object immediately.
85
+ const [snapshot] = useObject(person);
86
+ const [running, setRunning] = useState(true);
87
+
88
+ const mutate = useCallback(() => {
89
+ if (!person) {
90
+ return;
91
+ }
92
+ Obj.update(person, (person) => {
93
+ if (random.datatype.boolean()) {
94
+ person.fullName = random.person.fullName();
95
+ } else {
96
+ person.jobTitle = random.person.jobTitle();
97
+ }
98
+ });
99
+ }, [person]);
100
+
101
+ useEffect(() => {
102
+ if (!running || !person) {
103
+ return;
104
+ }
105
+ // Seed here — the mounted component is the RNG's sole consumer during its test — so the first tick is deterministic.
106
+ random.seed(REACTIVE_SEED);
107
+ const interval = setInterval(mutate, 1000);
108
+ return () => clearInterval(interval);
109
+ }, [running, person, mutate]);
110
+
111
+ if (!person) {
112
+ return <></>;
113
+ }
114
+
115
+ return (
116
+ <TestLayout json={snapshot}>
117
+ <div className='flex flex-col gap-2'>
118
+ <Toolbar.Root>
119
+ <Toolbar.IconButton
120
+ icon={running ? 'ph--pause--regular' : 'ph--play--regular'}
121
+ label={running ? 'Stop background mutations' : 'Start background mutations'}
122
+ onClick={() => setRunning((value) => !value)}
123
+ />
124
+ <Toolbar.IconButton
125
+ icon='ph--shuffle--regular'
126
+ label='Mutate a random field once'
127
+ disabled={running}
128
+ onClick={mutate}
129
+ />
130
+ </Toolbar.Root>
131
+ <ObjectForm object={person} type={Person.Person} />
132
+ </div>
133
+ </TestLayout>
134
+ );
135
+ };
136
+
137
+ export const Reactive: Story = {
138
+ render: () => <ReactiveStory />,
139
+ play: async ({ canvasElement }) => {
140
+ const canvas = within(canvasElement);
141
+
142
+ // The form initially reflects the live object (generous timeout: the client provider creates the identity/space
143
+ // asynchronously, which is slow under full-suite browser load).
144
+ await canvas.findByDisplayValue('Alice Carroll', {}, { timeout: 15_000 });
145
+
146
+ // The first background tick mutates `fullName` outside the form; the form reflects it reactively, so the initial
147
+ // value disappears. `waitFor` re-runs the selector until the assertion holds.
148
+ await waitFor(() => expect(canvas.queryByDisplayValue('Alice Carroll')).not.toBeInTheDocument(), {
149
+ timeout: 5_000,
150
+ });
151
+ },
152
+ };
@@ -2,9 +2,11 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ import * as Schema from 'effect/Schema';
5
6
  import React, { useCallback, useMemo } from 'react';
6
7
 
7
8
  import { Obj, Ref, Tag, Type } from '@dxos/echo';
9
+ import { useObject } from '@dxos/echo-react';
8
10
  import { SchemaEx } from '@dxos/effect';
9
11
  import { invariant } from '@dxos/invariant';
10
12
  import { HuePicker } from '@dxos/react-ui-pickers';
@@ -12,20 +14,36 @@ import { HuePicker } from '@dxos/react-ui-pickers';
12
14
  import { translationKey } from '#translations';
13
15
  import { type FormFieldMap } from '#types';
14
16
 
17
+ import { omitId } from '../../util';
15
18
  import { Form, META_TAGS_KEY, withMetaTags } from '../Form';
16
19
 
17
20
  export type ObjectFormProps = {
18
21
  type: Type.AnyEntity;
19
22
  object: Obj.Unknown;
23
+ /**
24
+ * Optional schema override for the rendered fields, e.g. a projection of `type`'s schema
25
+ * (`Type.getSchema(T).pipe(Schema.pick(...))`). Values are still read from and written back to
26
+ * `object` by path, so the picked fields must be paths on `object`. Defaults to `type`'s schema.
27
+ */
28
+ schema?: Schema.Schema.AnyNoContext;
29
+ /** Render the meta-tags field. Defaults to `true`. */
30
+ showTags?: boolean;
20
31
  };
21
32
 
22
- export const ObjectForm = ({ object, type }: ObjectFormProps) => {
33
+ export const ObjectForm = ({ object, type, schema, showTags = true }: ObjectFormProps) => {
23
34
  const db = Obj.getDatabase(object);
35
+ // Subscribe to the object so external/remote mutations re-render the form: ECHO reactivity is atom-based, so
36
+ // reading the raw object during render does not establish a subscription. `snapshot` is a fresh value on change.
37
+ const [snapshot] = useObject(object);
24
38
  const meta = Obj.getMeta(object);
25
39
  // `meta.tags` already holds `Ref<Tag>`s (materialized by the database handler).
26
40
  const tags = [...meta.tags];
27
- const values = useMemo(() => ({ [META_TAGS_KEY]: tags, ...object }), [object, tags]);
28
- const formSchema = useMemo(() => withMetaTags(Type.getSchema(type)), [type]);
41
+ const values = useMemo(() => ({ [META_TAGS_KEY]: tags, ...snapshot }), [snapshot, tags]);
42
+ const formSchema = useMemo(() => {
43
+ const base = schema ?? Type.getSchema(type);
44
+ // `withMetaTags` also drops `id`; keep it dropped when tags are omitted.
45
+ return showTags ? withMetaTags(base) : omitId(base);
46
+ }, [schema, type, showTags]);
29
47
 
30
48
  const handleCreate = useCallback((type: Type.AnyEntity, values: any) => {
31
49
  invariant(db);
@@ -79,7 +97,7 @@ export const ObjectForm = ({ object, type }: ObjectFormProps) => {
79
97
  return (
80
98
  <Form.Root
81
99
  schema={formSchema}
82
- defaultValues={values}
100
+ values={values}
83
101
  createTypename={Type.getTypename(Tag.Tag)}
84
102
  createOptionIcon='ph--plus--regular'
85
103
  createOptionLabel={['add-tag.label', { ns: translationKey }]}
@@ -8,8 +8,8 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
8
8
  import { expect, fn, userEvent, within } from 'storybook/test';
9
9
 
10
10
  import { Filter, Obj, Type } from '@dxos/echo';
11
+ import { useQuery } from '@dxos/echo-react';
11
12
  import { random } from '@dxos/random';
12
- import { useQuery } from '@dxos/react-client/echo';
13
13
  import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
14
14
  import { Button } from '@dxos/react-ui';
15
15
  import { withTheme } from '@dxos/react-ui/testing';
@@ -3,9 +3,9 @@
3
3
  //
4
4
 
5
5
  import type * as Schema from 'effect/Schema';
6
- import React, { type KeyboardEvent, forwardRef, useCallback, useState } from 'react';
6
+ import React, { type KeyboardEvent, useCallback, useState } from 'react';
7
7
 
8
- import { Popover, type ThemedClassName, useTranslation } from '@dxos/react-ui';
8
+ import { Popover, type ThemedClassName, composable, useTranslation } from '@dxos/react-ui';
9
9
  import { Combobox } from '@dxos/react-ui-list';
10
10
  import { useSearchListResults } from '@dxos/react-ui-search';
11
11
 
@@ -31,7 +31,7 @@ export type ObjectPickerContentProps = ThemedClassName<
31
31
  }
32
32
  >;
33
33
 
34
- const ObjectPickerContent = forwardRef<HTMLDivElement, ObjectPickerContentProps>(
34
+ const ObjectPickerContent = composable<HTMLDivElement, ObjectPickerContentProps>(
35
35
  (
36
36
  {
37
37
  options,
@@ -8,8 +8,8 @@ import React, { useEffect } from 'react';
8
8
  import { expect, userEvent, within } from 'storybook/test';
9
9
 
10
10
  import { Annotation, DXN, Filter, Obj, Ref, Tag, Type } from '@dxos/echo';
11
+ import { useQuery } from '@dxos/echo-react';
11
12
  import { FormInputAnnotation, LabelAnnotation } from '@dxos/echo/Annotation';
12
- import { useQuery } from '@dxos/react-client/echo';
13
13
  import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
14
14
  import { Panel } from '@dxos/react-ui';
15
15
  import { Loading, withLayout, withTheme } from '@dxos/react-ui/testing';
@@ -30,7 +30,13 @@ export const ObjectProperties = composable<HTMLDivElement, ObjectPropertiesProps
30
30
  const meta = Obj.getMeta(object);
31
31
  // `meta.tags` already holds `Ref<Tag>`s (materialized by the database handler).
32
32
  const tags = [...meta.tags];
33
- const values = useMemo(() => ({ [META_TAGS_KEY]: tags, ...object }), [object, tags]);
33
+ const values = useMemo(
34
+ () => ({
35
+ [META_TAGS_KEY]: tags,
36
+ ...object,
37
+ }),
38
+ [object, tags],
39
+ );
34
40
 
35
41
  // Obj.getType fails for database-registered (dynamic) schemas due to DXN mismatch;
36
42
  // useType queries by the object's stored type URI, resolving both static and dynamic schemas.
@@ -40,7 +40,7 @@ const value: ConfigProto = {
40
40
  subductionReplicator: true,
41
41
  signaling: true,
42
42
  },
43
- servicesMode: Runtime.Client.ServicesMode.SHARED_WORKER,
43
+ servicesMode: Runtime.Client.ServicesMode.DEDICATED_WORKER,
44
44
  enableSnapshots: false,
45
45
  snapshotInterval: 60_000,
46
46
  },
@@ -6,8 +6,8 @@ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
6
  import React, { Fragment, useState } from 'react';
7
7
 
8
8
  import { type Database, Filter, Obj } from '@dxos/echo';
9
+ import { useQuery } from '@dxos/echo-react';
9
10
  import { random } from '@dxos/random';
10
- import { useQuery } from '@dxos/react-client/echo';
11
11
  import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
12
12
  import { JsonHighlighter } from '@dxos/react-ui-syntax-highlighter';
13
13
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
@@ -145,3 +145,11 @@ export const WithEmail: Story = {
145
145
  value: 'someone@example.com ',
146
146
  },
147
147
  };
148
+
149
+ export const EmailModeWithValues: Story = {
150
+ args: {
151
+ mode: 'email',
152
+ activateOnTyping: true,
153
+ value: 'Alice Smith <alice@example.com>, bob@example.com, ',
154
+ },
155
+ };