@dxos/react-ui-form 0.8.4-main.d05539e30a → 0.8.4-main.d9fc60f731

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 (154) hide show
  1. package/dist/lib/browser/index.mjs +1335 -561
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/browser/translations.mjs +2 -1
  5. package/dist/lib/browser/translations.mjs.map +2 -2
  6. package/dist/lib/node-esm/index.mjs +1335 -561
  7. package/dist/lib/node-esm/index.mjs.map +4 -4
  8. package/dist/lib/node-esm/meta.json +1 -1
  9. package/dist/lib/node-esm/translations.mjs +2 -1
  10. package/dist/lib/node-esm/translations.mjs.map +2 -2
  11. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +2 -2
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  13. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +2 -1
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/Form.d.ts +35 -9
  16. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/Form.stories.d.ts +76 -16
  18. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/FormField.d.ts +14 -4
  20. package/dist/types/src/components/Form/FormField.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/FormFieldComponent.d.ts +22 -10
  22. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/FormFieldSet.d.ts +8 -2
  24. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/FormTooltipsContext.d.ts +19 -0
  26. package/dist/types/src/components/Form/FormTooltipsContext.d.ts.map +1 -0
  27. package/dist/types/src/components/Form/Layout/FormLayout.d.ts +50 -0
  28. package/dist/types/src/components/Form/Layout/FormLayout.d.ts.map +1 -0
  29. package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts +119 -0
  30. package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts.map +1 -0
  31. package/dist/types/src/components/Form/Layout/FormLayout.stories.test.d.ts +2 -0
  32. package/dist/types/src/components/Form/Layout/FormLayout.stories.test.d.ts.map +1 -0
  33. package/dist/types/src/components/Form/Layout/FormLayout.test.d.ts +2 -0
  34. package/dist/types/src/components/Form/Layout/FormLayout.test.d.ts.map +1 -0
  35. package/dist/types/src/components/Form/Layout/annotation.d.ts +30 -0
  36. package/dist/types/src/components/Form/Layout/annotation.d.ts.map +1 -0
  37. package/dist/types/src/components/Form/Layout/index.d.ts +4 -0
  38. package/dist/types/src/components/Form/Layout/index.d.ts.map +1 -0
  39. package/dist/types/src/components/Form/Layout/parser.d.ts +31 -0
  40. package/dist/types/src/components/Form/Layout/parser.d.ts.map +1 -0
  41. package/dist/types/src/components/Form/Layout/parser.test.d.ts +2 -0
  42. package/dist/types/src/components/Form/Layout/parser.test.d.ts.map +1 -0
  43. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
  44. package/dist/types/src/components/Form/fields/DateField.d.ts +4 -0
  45. package/dist/types/src/components/Form/fields/DateField.d.ts.map +1 -0
  46. package/dist/types/src/components/Form/fields/GeoPointField.d.ts +1 -1
  47. package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -1
  48. package/dist/types/src/components/Form/fields/InlineRefField.d.ts +13 -0
  49. package/dist/types/src/components/Form/fields/InlineRefField.d.ts.map +1 -0
  50. package/dist/types/src/components/Form/fields/MarkdownField.d.ts +1 -1
  51. package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -1
  52. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +2 -1
  53. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -1
  54. package/dist/types/src/components/Form/fields/RefField.d.ts +1 -1
  55. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
  56. package/dist/types/src/components/Form/fields/SelectField.d.ts +4 -1
  57. package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -1
  58. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts +1 -1
  59. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -1
  60. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +2 -1
  61. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -1
  62. package/dist/types/src/components/Form/fields/TupleField.d.ts +1 -1
  63. package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -1
  64. package/dist/types/src/components/Form/fields/index.d.ts +2 -0
  65. package/dist/types/src/components/Form/fields/index.d.ts.map +1 -1
  66. package/dist/types/src/components/Form/index.d.ts +3 -0
  67. package/dist/types/src/components/Form/index.d.ts.map +1 -1
  68. package/dist/types/src/components/Form/meta-tags.d.ts +30 -0
  69. package/dist/types/src/components/Form/meta-tags.d.ts.map +1 -0
  70. package/dist/types/src/components/Form/meta-tags.test.d.ts +2 -0
  71. package/dist/types/src/components/Form/meta-tags.test.d.ts.map +1 -0
  72. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +3 -4
  73. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -1
  74. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +2 -2
  75. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  76. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +2 -1
  77. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  78. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -1
  79. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +2 -1
  80. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -1
  81. package/dist/types/src/components/ObjectTree/ObjectTree.d.ts +9 -0
  82. package/dist/types/src/components/ObjectTree/ObjectTree.d.ts.map +1 -0
  83. package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts +87 -0
  84. package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts.map +1 -0
  85. package/dist/types/src/components/ObjectTree/index.d.ts +2 -0
  86. package/dist/types/src/components/ObjectTree/index.d.ts.map +1 -0
  87. package/dist/types/src/components/Settings/Settings.d.ts.map +1 -1
  88. package/dist/types/src/components/Settings/Settings.stories.d.ts +2 -1
  89. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -1
  90. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +7 -8
  91. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  92. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +4 -4
  93. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  94. package/dist/types/src/components/index.d.ts +1 -0
  95. package/dist/types/src/components/index.d.ts.map +1 -1
  96. package/dist/types/src/components/testing.d.ts +9 -0
  97. package/dist/types/src/components/testing.d.ts.map +1 -1
  98. package/dist/types/src/translations.d.ts +2 -1
  99. package/dist/types/src/translations.d.ts.map +1 -1
  100. package/dist/types/src/util/properties.d.ts +11 -0
  101. package/dist/types/src/util/properties.d.ts.map +1 -1
  102. package/dist/types/tsconfig.tsbuildinfo +1 -1
  103. package/package.json +37 -32
  104. package/src/components/FieldEditor/FieldEditor.stories.tsx +2 -2
  105. package/src/components/FieldEditor/FieldEditor.test.tsx +4 -1
  106. package/src/components/FieldEditor/FieldEditor.tsx +19 -14
  107. package/src/components/Form/Form.stories.tsx +21 -27
  108. package/src/components/Form/Form.tsx +106 -19
  109. package/src/components/Form/FormField.tsx +85 -28
  110. package/src/components/Form/FormFieldComponent.tsx +75 -20
  111. package/src/components/Form/FormFieldSet.tsx +32 -3
  112. package/src/components/Form/FormTooltipsContext.ts +25 -0
  113. package/src/components/Form/Layout/FormLayout.stories.test.tsx +51 -0
  114. package/src/components/Form/Layout/FormLayout.stories.tsx +422 -0
  115. package/src/components/Form/Layout/FormLayout.test.ts +59 -0
  116. package/src/components/Form/Layout/FormLayout.tsx +251 -0
  117. package/src/components/Form/Layout/annotation.ts +38 -0
  118. package/src/components/Form/Layout/index.ts +7 -0
  119. package/src/components/Form/Layout/parser.test.ts +120 -0
  120. package/src/components/Form/Layout/parser.ts +167 -0
  121. package/src/components/Form/fields/ArrayField.tsx +61 -38
  122. package/src/components/Form/fields/DateField.tsx +101 -0
  123. package/src/components/Form/fields/GeoPointField.tsx +4 -1
  124. package/src/components/Form/fields/InlineRefField.tsx +146 -0
  125. package/src/components/Form/fields/MarkdownField.tsx +3 -3
  126. package/src/components/Form/fields/RefField.tsx +29 -23
  127. package/src/components/Form/fields/SelectField.tsx +17 -5
  128. package/src/components/Form/fields/SelectOptionField.tsx +4 -3
  129. package/src/components/Form/fields/TextAreaField.tsx +1 -1
  130. package/src/components/Form/fields/TupleField.tsx +2 -1
  131. package/src/components/Form/fields/index.ts +2 -0
  132. package/src/components/Form/index.ts +3 -0
  133. package/src/components/Form/meta-tags.test.ts +47 -0
  134. package/src/components/Form/meta-tags.ts +33 -0
  135. package/src/components/ObjectForm/ObjectForm.tsx +21 -26
  136. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +2 -2
  137. package/src/components/ObjectPicker/ObjectPicker.tsx +2 -2
  138. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +14 -26
  139. package/src/components/ObjectProperties/ObjectProperties.test.tsx +20 -10
  140. package/src/components/ObjectProperties/ObjectProperties.tsx +26 -30
  141. package/src/components/ObjectTree/ObjectTree.stories.tsx +134 -0
  142. package/src/components/ObjectTree/ObjectTree.tsx +190 -0
  143. package/src/components/ObjectTree/index.ts +5 -0
  144. package/src/components/Settings/Settings.tsx +2 -1
  145. package/src/components/Settings/SettingsItem.tsx +1 -1
  146. package/src/components/ViewEditor/ViewEditor.stories.tsx +35 -40
  147. package/src/components/ViewEditor/ViewEditor.test.tsx +11 -8
  148. package/src/components/ViewEditor/ViewEditor.tsx +29 -32
  149. package/src/components/index.ts +1 -0
  150. package/src/components/testing.tsx +4 -4
  151. package/src/translations.ts +2 -1
  152. package/src/util/properties.test.ts +67 -9
  153. package/src/util/properties.ts +36 -4
  154. package/src/vite-env.d.ts +10 -0
@@ -7,9 +7,9 @@ import * as Option from 'effect/Option';
7
7
  import * as Schema from 'effect/Schema';
8
8
  import * as SchemaAST from 'effect/SchemaAST';
9
9
  import * as String from 'effect/String';
10
- import React, { useMemo } from 'react';
10
+ import React, { PropsWithChildren, useMemo } from 'react';
11
11
 
12
- import { Format } from '@dxos/echo';
12
+ import { Annotation, Format } from '@dxos/echo';
13
13
  import {
14
14
  createJsonPath,
15
15
  findNode,
@@ -20,8 +20,9 @@ import {
20
20
  isLiteralUnion,
21
21
  isNestedType,
22
22
  } from '@dxos/effect';
23
- import { useTranslation } from '@dxos/react-ui';
23
+ import { IconButton, IconButtonProps, useTranslation } from '@dxos/react-ui';
24
24
  import { type ProjectionModel } from '@dxos/schema';
25
+ import { mx } from '@dxos/ui-theme';
25
26
 
26
27
  import { translationKey } from '#translations';
27
28
 
@@ -29,7 +30,9 @@ import { getRefProps } from '../../util';
29
30
  import {
30
31
  ArrayField,
31
32
  BooleanField,
33
+ DateField,
32
34
  GeoPointField,
35
+ InlineRefField,
33
36
  MarkdownField,
34
37
  NumberField,
35
38
  RefField,
@@ -54,9 +57,14 @@ export type FormFieldProps = {
54
57
  type: SchemaAST.AST;
55
58
 
56
59
  /**
57
- * Name of the property.
60
+ * Name of the property. Used to derive a default label
61
+ * (`title ?? capitalize(name)`) and as the projection lookup key. Pass
62
+ * `null` to suppress the header label entirely -- the form still renders
63
+ * the field/struct, but `FormFieldSet`'s top-level `<FormFieldLabel>` is
64
+ * skipped. Used by `ArrayField` for object-array items, where every item
65
+ * would otherwise repeat the array's parent name.
58
66
  */
59
- name: string;
67
+ name: string | null;
60
68
 
61
69
  /**
62
70
  * Path to the current object from the root. Used with nested forms.
@@ -78,6 +86,7 @@ export type FormFieldProps = {
78
86
  * Function to lookup custom renderers for specific properties.
79
87
  */
80
88
  fieldProvider?: FormFieldProvider;
89
+
81
90
  /**
82
91
  * Typename of the ref type that the create props apply to.
83
92
  * When set, createOptionLabel/createOptionIcon/createInitialValuePath/createFieldMap/onCreate
@@ -92,7 +101,7 @@ export type FormFieldProps = {
92
101
  | 'createInitialValuePath'
93
102
  | 'createFieldMap'
94
103
  | 'db'
95
- | 'useSchema'
104
+ | 'useType'
96
105
  | 'getOptions'
97
106
  | 'onCreate'
98
107
  >;
@@ -115,7 +124,7 @@ export const FormField = (props: FormFieldProps) => {
115
124
  createInitialValuePath,
116
125
  createFieldMap,
117
126
  db,
118
- useSchema: schemaHook,
127
+ useType: schemaHook,
119
128
  getOptions,
120
129
  onCreate,
121
130
  } = props;
@@ -124,36 +133,53 @@ export const FormField = (props: FormFieldProps) => {
124
133
  const description = getAnnotation<string>(SchemaAST.DescriptionAnnotationId)(type);
125
134
  const examples = getAnnotation<string[]>(SchemaAST.ExamplesAnnotationId)(type);
126
135
 
127
- const label = useMemo(() => title ?? String.capitalize(name), [title, name]);
136
+ // `name === null` means "no header" -- collapse to an empty string so
137
+ // downstream consumers keep their `label: string` types, and the falsy
138
+ // value lets `FormFieldSet`'s `label && <FormFieldLabel ...>` guard skip
139
+ // the header.
140
+ const label = useMemo(() => title ?? (name == null ? '' : String.capitalize(name)), [title, name]);
128
141
  const placeholder = useMemo(
129
142
  () => (examples?.length ? `${t('example.placeholder')}: ${examples[0]}` : (description ?? label)),
130
143
  [examples, description, label],
131
144
  );
132
145
 
133
146
  const fieldState = useFormFieldState(FormField.displayName, path);
147
+ const jsonPath = createJsonPath(path ?? []);
134
148
  const fieldProps: FormFieldComponentProps = {
135
149
  type,
136
150
  format: Format.FormatAnnotation.getFromAst(type).pipe((annotation) => Option.getOrUndefined(annotation)),
137
151
  readonly,
138
152
  label,
153
+ jsonPath,
139
154
  placeholder,
140
155
  layout,
141
156
  db,
142
157
  ...fieldState,
143
158
  };
144
159
 
160
+ // Omit empty fields entirely in read-only mode -- an empty value has nothing
161
+ // to display, so a labelled row with a blank input is just noise. This
162
+ // mirrors what `FormFieldWrapper` already does for `layout === 'static'`, but
163
+ // covers every field type (including those that bypass the wrapper:
164
+ // RefField, SelectField, MarkdownField, ...). Container fields
165
+ // (`ArrayField`, nested-struct -> `FormFieldSet`) keep their own
166
+ // empty-value checks, but those branches only apply when the value is
167
+ // actually a non-null array/object, so this check doesn't interfere.
168
+ if (readonly && fieldState.getValue() == null) {
169
+ return null;
170
+ }
171
+
145
172
  //
146
173
  // Custom field.
147
174
  //
148
175
 
149
- const jsonPath = createJsonPath(path ?? []);
150
176
  const CustomField = fieldMap?.[jsonPath];
151
177
  if (CustomField) {
152
178
  return <CustomField {...fieldProps} />;
153
179
  }
154
180
 
155
181
  // TODO(burdon): Expensive to create schema each time; pass AST?
156
- const component = fieldProvider?.({ schema: Schema.make(type), prop: name, fieldProps });
182
+ const component = fieldProvider?.({ schema: Schema.make(type), prop: name ?? '', fieldProps });
157
183
  if (component) {
158
184
  return component;
159
185
  }
@@ -206,6 +232,12 @@ export const FormField = (props: FormFieldProps) => {
206
232
 
207
233
  const refProps = getRefProps(type);
208
234
  if (refProps) {
235
+ // Inline a single referenced object's own fields (nested form) instead of a picker.
236
+ const inline = Annotation.FormInlineAnnotation.getFromAst(refProps.ast).pipe(Option.getOrElse(() => false));
237
+ if (inline && !refProps.isArray) {
238
+ return <InlineRefField {...fieldProps} {...refProps} db={db} useType={schemaHook} onCreate={onCreate} />;
239
+ }
240
+
209
241
  const isCreateTarget = !createTypename || refProps.typename === createTypename;
210
242
  return (
211
243
  <RefField
@@ -216,7 +248,7 @@ export const FormField = (props: FormFieldProps) => {
216
248
  createInitialValuePath={isCreateTarget ? createInitialValuePath : undefined}
217
249
  createFieldMap={isCreateTarget ? createFieldMap : undefined}
218
250
  db={db}
219
- useSchema={schemaHook}
251
+ useType={schemaHook}
220
252
  getOptions={getOptions}
221
253
  onCreate={onCreate}
222
254
  />
@@ -236,23 +268,25 @@ export const FormField = (props: FormFieldProps) => {
236
268
  if (typeLiteral) {
237
269
  const schema = Schema.make(typeLiteral);
238
270
  return (
239
- <FormFieldSet
240
- schema={schema}
241
- path={path}
242
- readonly={readonly}
243
- layout={layout}
244
- label={label}
245
- projection={projection}
246
- fieldMap={fieldMap}
247
- fieldProvider={fieldProvider}
248
- createOptionLabel={createOptionLabel}
249
- createOptionIcon={createOptionIcon}
250
- createInitialValuePath={createInitialValuePath}
251
- db={db}
252
- useSchema={schemaHook}
253
- getOptions={getOptions}
254
- onCreate={onCreate}
255
- />
271
+ <Nesting>
272
+ <FormFieldSet
273
+ schema={schema}
274
+ path={path}
275
+ readonly={readonly}
276
+ layout={layout}
277
+ label={label}
278
+ projection={projection}
279
+ fieldMap={fieldMap}
280
+ fieldProvider={fieldProvider}
281
+ createOptionLabel={createOptionLabel}
282
+ createOptionIcon={createOptionIcon}
283
+ createInitialValuePath={createInitialValuePath}
284
+ db={db}
285
+ useType={schemaHook}
286
+ getOptions={getOptions}
287
+ onCreate={onCreate}
288
+ />
289
+ </Nesting>
256
290
  );
257
291
  }
258
292
  }
@@ -262,6 +296,26 @@ export const FormField = (props: FormFieldProps) => {
262
296
 
263
297
  FormField.displayName = 'Form.FormField';
264
298
 
299
+ //
300
+ // Layout components
301
+ //
302
+
303
+ // TODO(burdon): Options (nested or flat).
304
+ // TODO(burdon): Support collapsible.
305
+ export const Nesting = ({ children }: PropsWithChildren) => (
306
+ <div className='px-2 border border-subdued-separator rounded-sm mt-2'>
307
+ <div className='pb-2'>{children}</div>
308
+ </div>
309
+ );
310
+
311
+ export const IconBlock = ({ inline, ...props }: IconButtonProps & { inline?: boolean }) => {
312
+ return (
313
+ <div className={mx('h-full flex px-1', inline ? 'items-center' : 'flex-col pt-2.5')}>
314
+ <IconButton variant='ghost' density='xs' square iconOnly {...props} />
315
+ </div>
316
+ );
317
+ };
318
+
265
319
  /**
266
320
  * Get property input component.
267
321
  */
@@ -272,9 +326,12 @@ const getFormField = ({ type, format }: FormFieldComponentProps): FormFieldCompo
272
326
 
273
327
  const formatField = Match.value(format).pipe(
274
328
  Match.withReturnType<FormFieldComponent | undefined>(),
329
+ Match.when(Format.TypeFormat.Date, () => DateField),
330
+ Match.when(Format.TypeFormat.DateTime, () => DateField),
275
331
  Match.when(Format.TypeFormat.GeoPoint, () => GeoPointField),
276
332
  Match.when(Format.TypeFormat.Markdown, () => MarkdownField),
277
333
  Match.when(Format.TypeFormat.Text, () => TextAreaField),
334
+ Match.when(Format.TypeFormat.Time, () => DateField),
278
335
  Match.orElse(() => undefined),
279
336
  );
280
337
  if (formatField) {
@@ -2,6 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
+ import { format as formatDate } from 'date-fns';
5
6
  import type * as Schema from 'effect/Schema';
6
7
  import type * as SchemaAST from 'effect/SchemaAST';
7
8
  import React, {
@@ -13,12 +14,13 @@ import React, {
13
14
  type ReactNode,
14
15
  } from 'react';
15
16
 
16
- import { type Database } from '@dxos/echo';
17
- import { type Format } from '@dxos/echo/internal';
17
+ import { type Database, Format } from '@dxos/echo';
18
18
  import { Icon, Input, Tooltip } from '@dxos/react-ui';
19
- import { inputTextLabel } from '@dxos/ui-theme';
19
+ import { inputTextLabel } from '@dxos/react-ui';
20
+ import { mx } from '@dxos/ui-theme';
20
21
 
21
22
  import { type FormFieldStatus } from '../../hooks';
23
+ import { useFormTooltips } from './FormTooltipsContext';
22
24
 
23
25
  /**
24
26
  * Dynamic props passed to input components.
@@ -44,29 +46,33 @@ export type Presentation = 'full' | 'compact' | 'inline' | 'static';
44
46
  * Props passed to input components.
45
47
  */
46
48
  export type FormFieldComponentProps<T = any> = {
49
+ /** Database the form is editing against; populated for fields whose value is an ECHO object/Ref. */
50
+ db?: Database.Database;
47
51
  type: SchemaAST.AST;
48
52
  format?: Format.TypeFormat;
49
53
  readonly?: boolean;
50
54
  label: string;
55
+ /**
56
+ * Dotted JSON path of this field within the form values (e.g.
57
+ * `runtime.client.storage.persistent`). Forwarded to `FormFieldLabel` so
58
+ * that it can render the path as a debug hint when the enclosing
59
+ * `Form.Root` has `debug` enabled.
60
+ */
61
+ jsonPath?: string;
51
62
  placeholder?: string;
52
63
  autoFocus?: boolean;
53
64
  layout?: Presentation;
54
- /** Database the form is editing against; populated for fields whose value is an ECHO object/Ref. */
55
- db?: Database.Database;
56
65
  } & FormFieldStateProps<T>;
57
66
 
58
- /**
59
- * Form field component.
60
- */
61
67
  export type FormFieldComponent = FC<FormFieldComponentProps>;
62
68
 
63
69
  export type FormFieldMap = Record<string, FormFieldComponent>;
64
70
 
65
71
  export type FormFieldProvider = (props: {
66
- prop: string; // TODO(burdon): Path?
72
+ prop: string;
67
73
  schema: Schema.Schema<any>;
68
74
  fieldProps: FormFieldComponentProps;
69
- }) => ReactElement | undefined;
75
+ }) => ReactElement | null | undefined;
70
76
 
71
77
  //
72
78
  // FormFieldLabel
@@ -75,13 +81,30 @@ export type FormFieldProvider = (props: {
75
81
  export type FormFieldLabelProps = {
76
82
  asChild?: boolean;
77
83
  error?: string;
84
+ /**
85
+ * JSON path of the field this label describes (e.g. `runtime.client.storage.persistent`).
86
+ * Surfaced as a hover tooltip on the label when the enclosing `Form.Root`
87
+ * has `tooltips` enabled (the default) -- useful for spotting which field
88
+ * maps to which path when authoring schemas or filing bugs against a form.
89
+ * Callers can supply the path unconditionally; the label suppresses the
90
+ * tooltip when `Form.Root tooltips={false}`.
91
+ */
92
+ path?: string;
78
93
  } & Pick<FormFieldComponentProps, 'label' | 'readonly'>;
79
94
 
80
- export const FormFieldLabel = ({ label, error, readonly, asChild }: FormFieldLabelProps) => {
95
+ export const FormFieldLabel = ({ label, error, readonly, asChild, path }: FormFieldLabelProps) => {
96
+ const tooltips = useFormTooltips();
81
97
  const Label = readonly || asChild ? 'span' : Input.Label;
98
+ const labelNode = <Label className={mx(inputTextLabel, 'text-sm')}>{label}</Label>;
82
99
  return (
83
100
  <div className='flex items-center justify-between'>
84
- <Label className={inputTextLabel}>{label}</Label>
101
+ {tooltips && path ? (
102
+ <Tooltip.Trigger asChild content={path} side='bottom'>
103
+ {labelNode}
104
+ </Tooltip.Trigger>
105
+ ) : (
106
+ labelNode
107
+ )}
85
108
  {error && (
86
109
  <Tooltip.Trigger asChild content={error} side='bottom'>
87
110
  <Icon icon='ph--warning--regular' size={4} classNames='text-error-text' />
@@ -99,13 +122,39 @@ FormFieldLabel.displayName = 'Form.FieldLabel';
99
122
 
100
123
  export type FormFieldWrapperProps<T = any> = Pick<
101
124
  FormFieldComponentProps,
102
- 'readonly' | 'label' | 'layout' | 'getStatus' | 'getValue'
125
+ 'readonly' | 'label' | 'layout' | 'getStatus' | 'getValue' | 'jsonPath' | 'format'
103
126
  > & {
104
127
  children?: (props: { value: T }) => ReactNode;
105
128
  };
106
129
 
130
+ /**
131
+ * Formats a value for `static` (read-only, plain-DOM) presentation based on its
132
+ * type `format`. Dates/times are rendered human-readable; everything else falls
133
+ * back to `String(value)`.
134
+ */
135
+ const formatStaticValue = (value: unknown, format?: Format.TypeFormat): string => {
136
+ if (value == null) {
137
+ return '';
138
+ }
139
+
140
+ switch (format) {
141
+ case Format.TypeFormat.DateTime:
142
+ case Format.TypeFormat.Date:
143
+ case Format.TypeFormat.Time: {
144
+ const date = new Date(value as string);
145
+ if (Number.isNaN(date.getTime())) {
146
+ return String(value);
147
+ }
148
+ const pattern = format === Format.TypeFormat.DateTime ? 'PPp' : format === Format.TypeFormat.Date ? 'PP' : 'p';
149
+ return formatDate(date, pattern);
150
+ }
151
+ default:
152
+ return String(value);
153
+ }
154
+ };
155
+
107
156
  export const FormFieldWrapper = <T,>(props: FormFieldWrapperProps<T>) => {
108
- const { children, readonly, layout, label, getStatus, getValue } = props;
157
+ const { children, readonly, layout, label, jsonPath, format, getStatus, getValue } = props;
109
158
  const { status, error } = getStatus();
110
159
 
111
160
  const value = getValue();
@@ -113,14 +162,20 @@ export const FormFieldWrapper = <T,>(props: FormFieldWrapperProps<T>) => {
113
162
  return null;
114
163
  }
115
164
 
116
- const str = String(value ?? '');
165
+ const str = formatStaticValue(value, format);
117
166
 
118
167
  return (
119
168
  <div className='contents'>
120
169
  <Input.Root validationValence={status}>
121
- {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
122
- {layout === 'static' ? <p>{str}</p> : children ? children({ value }) : null}
123
- {layout === 'full' && (
170
+ {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} path={jsonPath} />}
171
+ {layout === 'static' ? (
172
+ <p className='truncate min-w-0' title={str}>
173
+ {str}
174
+ </p>
175
+ ) : children ? (
176
+ children({ value })
177
+ ) : null}
178
+ {layout === 'full' && error && (
124
179
  <Input.DescriptionAndValidation>
125
180
  <Input.Validation>{error}</Input.Validation>
126
181
  </Input.DescriptionAndValidation>
@@ -158,8 +213,8 @@ export class FormFieldErrorBoundary extends Component<FormFieldErrorBoundaryProp
158
213
  override render() {
159
214
  if (this.state.error) {
160
215
  return (
161
- <div className='flex gap-2 border border-rose-fill font-mono text-sm'>
162
- <span className='bg-rose-fill text-base-foreground px-1 font-thin'>ERROR</span>
216
+ <div className='flex gap-2 border border-rose-bg font-mono text-sm'>
217
+ <span className='bg-rose-bg text-base-fg px-1 font-thin'>ERROR</span>
163
218
  {String(this.props.path?.join('.'))}
164
219
  </div>
165
220
  );
@@ -2,16 +2,18 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
+ import * as Option from 'effect/Option';
5
6
  import React, { useMemo } from 'react';
6
7
 
7
8
  import { type AnyProperties } from '@dxos/echo/internal';
8
- import { type SchemaProperty } from '@dxos/effect';
9
+ import { createJsonPath, type SchemaProperty } from '@dxos/effect';
9
10
 
10
11
  import { type FormHandlerProps } from '../../hooks';
11
12
  import { getFormProperties } from '../../util';
12
13
  import { useFormValues } from './Form';
13
14
  import { FormField, type FormFieldProps } from './FormField';
14
15
  import { FormFieldErrorBoundary, FormFieldLabel } from './FormFieldComponent';
16
+ import { DEFAULT_LAYOUT_NAME, FormLayout, FormLayoutAnnotation } from './Layout';
15
17
 
16
18
  const FORM_FIELDSET_NAME = 'Form.FieldSet';
17
19
 
@@ -19,6 +21,12 @@ export type FormFieldSetProps<T extends AnyProperties> = {
19
21
  label?: string;
20
22
  sort?: string[];
21
23
  exclude?: (props: SchemaProperty[]) => SchemaProperty[];
24
+ /**
25
+ * Picks a named layout out of `FormLayoutAnnotation` when present.
26
+ * Falls back to `'default'`. Ignored when the schema has no annotation
27
+ * (linear rendering then takes over).
28
+ */
29
+ layoutName?: string;
22
30
  } & Pick<FormHandlerProps<T>, 'schema'> &
23
31
  Pick<
24
32
  FormFieldProps,
@@ -35,7 +43,7 @@ export type FormFieldSetProps<T extends AnyProperties> = {
35
43
  | 'createInitialValuePath'
36
44
  | 'createFieldMap'
37
45
  | 'db'
38
- | 'useSchema'
46
+ | 'useType'
39
47
  | 'getOptions'
40
48
  | 'onCreate'
41
49
  >;
@@ -52,6 +60,7 @@ export const FormFieldSet = ({
52
60
  exclude,
53
61
  projection,
54
62
  layout,
63
+ layoutName = DEFAULT_LAYOUT_NAME,
55
64
  ...props
56
65
  }: FormFieldSetProps<any>) => {
57
66
  const values = useFormValues(FORM_FIELDSET_NAME, path);
@@ -60,9 +69,29 @@ export const FormFieldSet = ({
60
69
  return null;
61
70
  }
62
71
 
72
+ // If the schema carries a layout template, hand off to <Form.Layout/> which renders the DSL.
73
+ // Linear rendering still runs when no annotation is present, so existing call sites are unchanged.
74
+ const layouts = schema ? Option.getOrUndefined(FormLayoutAnnotation.get(schema)) : undefined;
75
+ if (layouts?.[layoutName] !== undefined && schema) {
76
+ return (
77
+ <>
78
+ {layout !== 'inline' && label && <FormFieldLabel label={label} path={createJsonPath(path ?? [])} asChild />}
79
+ <FormLayout
80
+ schema={schema}
81
+ name={layoutName}
82
+ path={path}
83
+ readonly={readonly}
84
+ layout={layout}
85
+ projection={projection}
86
+ {...props}
87
+ />
88
+ </>
89
+ );
90
+ }
91
+
63
92
  return (
64
93
  <>
65
- {layout !== 'inline' && label && <FormFieldLabel label={label} asChild />}
94
+ {layout !== 'inline' && label && <FormFieldLabel label={label} path={createJsonPath(path ?? [])} asChild />}
66
95
  {properties.map((property) => {
67
96
  const name = property.name.toString();
68
97
  return (
@@ -0,0 +1,25 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { createContext, useContext } from 'react';
6
+
7
+ /**
8
+ * A standalone context for the `tooltips` flag (Form.Root's only label
9
+ * decoration today: a JSON-path hover tooltip rendered by
10
+ * `FormFieldLabel`).
11
+ *
12
+ * Lives in its own module so that `FormFieldComponent.tsx` -- imported at
13
+ * the top of `Form.tsx` -- can read the flag without re-importing from
14
+ * `Form.tsx`. That round-trip used to create a circular module-eval
15
+ * dependency that intermittently hit a TDZ ("Cannot access 'FormFieldLabel'
16
+ * before initialization") in browser-mode storybook tests.
17
+ *
18
+ * Defaults to `true` both when `Form.Root` doesn't pass an explicit value
19
+ * AND when there is no enclosing `Form.Root` (e.g. `FormFieldWrapper`
20
+ * stories that render an input without a Form provider).
21
+ */
22
+ export const FormTooltipsContext = createContext<boolean>(true);
23
+
24
+ /** Returns whether the enclosing `Form.Root` has field tooltips enabled. */
25
+ export const useFormTooltips = (): boolean => useContext(FormTooltipsContext);
@@ -0,0 +1,51 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { composeStories } from '@storybook/react';
6
+ import { act, cleanup, screen, within } from '@testing-library/react';
7
+ import { afterEach, describe, expect, test } from 'vitest';
8
+
9
+ import * as stories from './FormLayout.stories';
10
+
11
+ const { NestedLabel, NestedLabelStatic } = composeStories(stories);
12
+
13
+ describe('FormLayout — nested fields', () => {
14
+ afterEach(async () => {
15
+ // Flush pending React scheduler work before teardown to prevent
16
+ // "window is not defined" errors from setImmediate callbacks firing after happy-dom cleanup.
17
+ await act(async () => {});
18
+ cleanup();
19
+ });
20
+
21
+ test('renders nested struct labels and dotted sub-fields', { timeout: 30_000 }, async () => {
22
+ await NestedLabel.run();
23
+
24
+ // `<field name="origin"/>` / `<field name="destination"/>` auto-convert to the
25
+ // place's `LabelAnnotation` label (the `name` field), rendered as read-only text.
26
+ // `findByText` retries while the async client provider initializes.
27
+ expect(await screen.findByText('John F. Kennedy Intl')).toBeInTheDocument();
28
+ expect(await screen.findByText('Charles de Gaulle')).toBeInTheDocument();
29
+
30
+ // `<field name="origin.code"/>` / `<field name="destination.code"/>` drill into the
31
+ // leaf sub-field, rendered as editable inputs holding the code.
32
+ expect(await screen.findByDisplayValue('JFK')).toBeInTheDocument();
33
+ expect(await screen.findByDisplayValue('CDG')).toBeInTheDocument();
34
+ });
35
+
36
+ test('static layout formats dates and renders labels as read-only text', { timeout: 30_000 }, async () => {
37
+ await NestedLabelStatic.run();
38
+
39
+ // Scope assertions to the form: the debug panel echoes the raw values JSON.
40
+ const form = await screen.findByRole('form');
41
+
42
+ // The depart date renders human-readable (contains the year) rather than the raw ISO
43
+ // string. Match `2026` and assert no ISO time fragment leaks through — timezone-robust.
44
+ expect(within(form).getByText(/2026/)).toBeInTheDocument();
45
+ expect(within(form).queryByText(/T\d\d:\d\d/)).toBeNull();
46
+
47
+ // Nested struct labels and dotted sub-fields render as static text (not inputs).
48
+ expect(within(form).getByText('John F. Kennedy Intl')).toBeInTheDocument();
49
+ expect(within(form).getByText('JFK')).toBeInTheDocument();
50
+ });
51
+ });