@dxos/react-ui-form 0.8.4-main.d05673bc65 → 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 (209) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +2008 -840
  3. package/dist/lib/browser/index.mjs.map +4 -4
  4. package/dist/lib/browser/meta.json +1 -1
  5. package/dist/lib/browser/translations.mjs +83 -0
  6. package/dist/lib/browser/translations.mjs.map +7 -0
  7. package/dist/lib/node-esm/index.mjs +2008 -840
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/lib/node-esm/translations.mjs +85 -0
  11. package/dist/lib/node-esm/translations.mjs.map +7 -0
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +2 -2
  13. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +64 -61
  15. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  16. package/dist/types/src/components/Form/Form.d.ts +71 -17
  17. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  18. package/dist/types/src/components/Form/Form.stories.d.ts +148 -86
  19. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  20. package/dist/types/src/components/Form/FormField.d.ts +24 -8
  21. package/dist/types/src/components/Form/FormField.d.ts.map +1 -1
  22. package/dist/types/src/components/Form/FormFieldComponent.d.ts +26 -13
  23. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
  24. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/FormFieldSet.d.ts +11 -9
  26. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
  27. package/dist/types/src/components/Form/FormTooltipsContext.d.ts +19 -0
  28. package/dist/types/src/components/Form/FormTooltipsContext.d.ts.map +1 -0
  29. package/dist/types/src/components/Form/Layout/FormLayout.d.ts +50 -0
  30. package/dist/types/src/components/Form/Layout/FormLayout.d.ts.map +1 -0
  31. package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts +119 -0
  32. package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts.map +1 -0
  33. package/dist/types/src/components/Form/Layout/FormLayout.stories.test.d.ts +2 -0
  34. package/dist/types/src/components/Form/Layout/FormLayout.stories.test.d.ts.map +1 -0
  35. package/dist/types/src/components/Form/Layout/FormLayout.test.d.ts +2 -0
  36. package/dist/types/src/components/Form/Layout/FormLayout.test.d.ts.map +1 -0
  37. package/dist/types/src/components/Form/Layout/annotation.d.ts +30 -0
  38. package/dist/types/src/components/Form/Layout/annotation.d.ts.map +1 -0
  39. package/dist/types/src/components/Form/Layout/index.d.ts +4 -0
  40. package/dist/types/src/components/Form/Layout/index.d.ts.map +1 -0
  41. package/dist/types/src/components/Form/Layout/parser.d.ts +31 -0
  42. package/dist/types/src/components/Form/Layout/parser.d.ts.map +1 -0
  43. package/dist/types/src/components/Form/Layout/parser.test.d.ts +2 -0
  44. package/dist/types/src/components/Form/Layout/parser.test.d.ts.map +1 -0
  45. package/dist/types/src/components/Form/fields/ArrayField.d.ts +4 -4
  46. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
  47. package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -1
  48. package/dist/types/src/components/Form/fields/DateField.d.ts +4 -0
  49. package/dist/types/src/components/Form/fields/DateField.d.ts.map +1 -0
  50. package/dist/types/src/components/Form/fields/GeoPointField.d.ts +1 -1
  51. package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -1
  52. package/dist/types/src/components/Form/fields/InlineRefField.d.ts +13 -0
  53. package/dist/types/src/components/Form/fields/InlineRefField.d.ts.map +1 -0
  54. package/dist/types/src/components/Form/fields/MarkdownField.d.ts +8 -1
  55. package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -1
  56. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +85 -0
  57. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -0
  58. package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -1
  59. package/dist/types/src/components/Form/fields/RefField.d.ts +12 -6
  60. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
  61. package/dist/types/src/components/Form/fields/SelectField.d.ts +4 -1
  62. package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -1
  63. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts +1 -1
  64. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -1
  65. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +64 -61
  66. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -1
  67. package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -1
  68. package/dist/types/src/components/Form/fields/TextField.d.ts +1 -1
  69. package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -1
  70. package/dist/types/src/components/Form/fields/TupleField.d.ts +1 -1
  71. package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -1
  72. package/dist/types/src/components/Form/fields/index.d.ts +2 -0
  73. package/dist/types/src/components/Form/fields/index.d.ts.map +1 -1
  74. package/dist/types/src/components/Form/index.d.ts +3 -0
  75. package/dist/types/src/components/Form/index.d.ts.map +1 -1
  76. package/dist/types/src/components/Form/meta-tags.d.ts +30 -0
  77. package/dist/types/src/components/Form/meta-tags.d.ts.map +1 -0
  78. package/dist/types/src/components/Form/meta-tags.test.d.ts +2 -0
  79. package/dist/types/src/components/Form/meta-tags.test.d.ts.map +1 -0
  80. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +8 -0
  81. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -0
  82. package/dist/types/src/components/ObjectForm/index.d.ts +2 -0
  83. package/dist/types/src/components/ObjectForm/index.d.ts.map +1 -0
  84. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +28 -6
  85. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  86. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +64 -61
  87. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  88. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +9 -0
  89. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -0
  90. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +128 -0
  91. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -0
  92. package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts +2 -0
  93. package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts.map +1 -0
  94. package/dist/types/src/components/ObjectProperties/index.d.ts +2 -0
  95. package/dist/types/src/components/ObjectProperties/index.d.ts.map +1 -0
  96. package/dist/types/src/components/ObjectTree/ObjectTree.d.ts +9 -0
  97. package/dist/types/src/components/ObjectTree/ObjectTree.d.ts.map +1 -0
  98. package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts +87 -0
  99. package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts.map +1 -0
  100. package/dist/types/src/components/ObjectTree/index.d.ts +2 -0
  101. package/dist/types/src/components/ObjectTree/index.d.ts.map +1 -0
  102. package/dist/types/src/components/Settings/Settings.d.ts +28 -33
  103. package/dist/types/src/components/Settings/Settings.d.ts.map +1 -1
  104. package/dist/types/src/components/Settings/Settings.stories.d.ts +65 -61
  105. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -1
  106. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +43 -0
  107. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +1 -0
  108. package/dist/types/src/components/Settings/SettingsItem.d.ts +16 -0
  109. package/dist/types/src/components/Settings/SettingsItem.d.ts.map +1 -0
  110. package/dist/types/src/components/Settings/SettingsSelectField.d.ts +18 -0
  111. package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +1 -0
  112. package/dist/types/src/components/Settings/index.d.ts +2 -0
  113. package/dist/types/src/components/Settings/index.d.ts.map +1 -1
  114. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +9 -11
  115. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  116. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +68 -66
  117. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  118. package/dist/types/src/components/index.d.ts +3 -0
  119. package/dist/types/src/components/index.d.ts.map +1 -1
  120. package/dist/types/src/components/testing.d.ts +13 -4
  121. package/dist/types/src/components/testing.d.ts.map +1 -1
  122. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  123. package/dist/types/src/hooks/useKeyHandler.d.ts +2 -1
  124. package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -1
  125. package/dist/types/src/index.d.ts +0 -2
  126. package/dist/types/src/index.d.ts.map +1 -1
  127. package/dist/types/src/translations.d.ts +64 -61
  128. package/dist/types/src/translations.d.ts.map +1 -1
  129. package/dist/types/src/util/field-type.d.ts +19 -0
  130. package/dist/types/src/util/field-type.d.ts.map +1 -0
  131. package/dist/types/src/util/field.d.ts.map +1 -1
  132. package/dist/types/src/util/formatting.d.ts.map +1 -1
  133. package/dist/types/src/util/index.d.ts +1 -0
  134. package/dist/types/src/util/index.d.ts.map +1 -1
  135. package/dist/types/src/util/properties.d.ts +18 -1
  136. package/dist/types/src/util/properties.d.ts.map +1 -1
  137. package/dist/types/src/util/properties.test.d.ts +2 -0
  138. package/dist/types/src/util/properties.test.d.ts.map +1 -0
  139. package/dist/types/src/util/refs.d.ts +1 -0
  140. package/dist/types/src/util/refs.d.ts.map +1 -1
  141. package/dist/types/tsconfig.tsbuildinfo +1 -1
  142. package/package.json +48 -41
  143. package/src/components/FieldEditor/FieldEditor.stories.tsx +4 -4
  144. package/src/components/FieldEditor/FieldEditor.test.tsx +4 -2
  145. package/src/components/FieldEditor/FieldEditor.tsx +23 -17
  146. package/src/components/Form/AUDIT.md +17 -0
  147. package/src/components/Form/Form.stories.tsx +46 -65
  148. package/src/components/Form/Form.tsx +176 -60
  149. package/src/components/Form/FormField.tsx +107 -38
  150. package/src/components/Form/FormFieldComponent.tsx +77 -20
  151. package/src/components/Form/FormFieldSet.tsx +128 -78
  152. package/src/components/Form/FormTooltipsContext.ts +25 -0
  153. package/src/components/Form/Layout/FormLayout.stories.test.tsx +51 -0
  154. package/src/components/Form/Layout/FormLayout.stories.tsx +422 -0
  155. package/src/components/Form/Layout/FormLayout.test.ts +59 -0
  156. package/src/components/Form/Layout/FormLayout.tsx +251 -0
  157. package/src/components/Form/Layout/annotation.ts +38 -0
  158. package/src/components/Form/Layout/index.ts +7 -0
  159. package/src/components/Form/Layout/parser.test.ts +120 -0
  160. package/src/components/Form/Layout/parser.ts +167 -0
  161. package/src/components/Form/fields/ArrayField.tsx +64 -35
  162. package/src/components/Form/fields/DateField.tsx +101 -0
  163. package/src/components/Form/fields/GeoPointField.tsx +12 -8
  164. package/src/components/Form/fields/InlineRefField.tsx +146 -0
  165. package/src/components/Form/fields/MarkdownField.stories.tsx +206 -0
  166. package/src/components/Form/fields/MarkdownField.tsx +135 -45
  167. package/src/components/Form/fields/RefField.tsx +83 -42
  168. package/src/components/Form/fields/SelectField.tsx +17 -5
  169. package/src/components/Form/fields/SelectOptionField.stories.tsx +8 -6
  170. package/src/components/Form/fields/SelectOptionField.tsx +14 -13
  171. package/src/components/Form/fields/TextAreaField.tsx +1 -1
  172. package/src/components/Form/fields/TextField.tsx +2 -2
  173. package/src/components/Form/fields/TupleField.tsx +3 -2
  174. package/src/components/Form/fields/index.ts +2 -0
  175. package/src/components/Form/index.ts +3 -0
  176. package/src/components/Form/meta-tags.test.ts +47 -0
  177. package/src/components/Form/meta-tags.ts +33 -0
  178. package/src/components/ObjectForm/ObjectForm.tsx +109 -0
  179. package/src/components/ObjectForm/index.ts +5 -0
  180. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +13 -11
  181. package/src/components/ObjectPicker/ObjectPicker.tsx +93 -66
  182. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +350 -0
  183. package/src/components/ObjectProperties/ObjectProperties.test.tsx +111 -0
  184. package/src/components/ObjectProperties/ObjectProperties.tsx +144 -0
  185. package/src/components/ObjectProperties/index.ts +5 -0
  186. package/src/components/ObjectTree/ObjectTree.stories.tsx +134 -0
  187. package/src/components/ObjectTree/ObjectTree.tsx +190 -0
  188. package/src/components/ObjectTree/index.ts +5 -0
  189. package/src/components/Settings/AUDIT.md +51 -0
  190. package/src/components/Settings/Settings.stories.tsx +69 -21
  191. package/src/components/Settings/Settings.tsx +32 -181
  192. package/src/components/Settings/SettingsFieldSet.tsx +257 -0
  193. package/src/components/Settings/SettingsItem.tsx +58 -0
  194. package/src/components/Settings/SettingsSelectField.tsx +59 -0
  195. package/src/components/Settings/index.ts +2 -0
  196. package/src/components/ViewEditor/ViewEditor.stories.tsx +45 -51
  197. package/src/components/ViewEditor/ViewEditor.test.tsx +11 -9
  198. package/src/components/ViewEditor/ViewEditor.tsx +45 -55
  199. package/src/components/index.ts +3 -0
  200. package/src/components/testing.tsx +19 -11
  201. package/src/hooks/useKeyHandler.tsx +7 -7
  202. package/src/index.ts +0 -4
  203. package/src/translations.ts +67 -63
  204. package/src/util/field-type.ts +60 -0
  205. package/src/util/index.ts +1 -0
  206. package/src/util/properties.test.ts +121 -0
  207. package/src/util/properties.ts +47 -4
  208. package/src/util/refs.ts +7 -3
  209. package/src/vite-env.d.ts +10 -0
@@ -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,11 +14,13 @@ import React, {
13
14
  type ReactNode,
14
15
  } from 'react';
15
16
 
16
- import { type Format } from '@dxos/echo/internal';
17
+ import { type Database, Format } from '@dxos/echo';
17
18
  import { Icon, Input, Tooltip } from '@dxos/react-ui';
18
- import { inputTextLabel } from '@dxos/ui-theme';
19
+ import { inputTextLabel } from '@dxos/react-ui';
20
+ import { mx } from '@dxos/ui-theme';
19
21
 
20
22
  import { type FormFieldStatus } from '../../hooks';
23
+ import { useFormTooltips } from './FormTooltipsContext';
21
24
 
22
25
  /**
23
26
  * Dynamic props passed to input components.
@@ -31,7 +34,6 @@ export type FormFieldStateProps<T = any> = {
31
34
 
32
35
  /**
33
36
  * Presentation mode.
34
- *
35
37
  * - full: Show label, control, and status.
36
38
  * - compact: Show label and control.
37
39
  * - inline: Control only.
@@ -44,27 +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
65
  } & FormFieldStateProps<T>;
55
66
 
56
- /**
57
- * Form field component.
58
- */
59
67
  export type FormFieldComponent = FC<FormFieldComponentProps>;
60
68
 
61
69
  export type FormFieldMap = Record<string, FormFieldComponent>;
62
70
 
63
71
  export type FormFieldProvider = (props: {
64
- prop: string; // TODO(burdon): Path?
72
+ prop: string;
65
73
  schema: Schema.Schema<any>;
66
74
  fieldProps: FormFieldComponentProps;
67
- }) => ReactElement | undefined;
75
+ }) => ReactElement | null | undefined;
68
76
 
69
77
  //
70
78
  // FormFieldLabel
@@ -73,13 +81,30 @@ export type FormFieldProvider = (props: {
73
81
  export type FormFieldLabelProps = {
74
82
  asChild?: boolean;
75
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;
76
93
  } & Pick<FormFieldComponentProps, 'label' | 'readonly'>;
77
94
 
78
- export const FormFieldLabel = ({ label, error, readonly, asChild }: FormFieldLabelProps) => {
95
+ export const FormFieldLabel = ({ label, error, readonly, asChild, path }: FormFieldLabelProps) => {
96
+ const tooltips = useFormTooltips();
79
97
  const Label = readonly || asChild ? 'span' : Input.Label;
98
+ const labelNode = <Label className={mx(inputTextLabel, 'text-sm')}>{label}</Label>;
80
99
  return (
81
- <div role='none' className='flex items-center justify-between'>
82
- <Label className={inputTextLabel}>{label}</Label>
100
+ <div className='flex items-center justify-between'>
101
+ {tooltips && path ? (
102
+ <Tooltip.Trigger asChild content={path} side='bottom'>
103
+ {labelNode}
104
+ </Tooltip.Trigger>
105
+ ) : (
106
+ labelNode
107
+ )}
83
108
  {error && (
84
109
  <Tooltip.Trigger asChild content={error} side='bottom'>
85
110
  <Icon icon='ph--warning--regular' size={4} classNames='text-error-text' />
@@ -97,13 +122,39 @@ FormFieldLabel.displayName = 'Form.FieldLabel';
97
122
 
98
123
  export type FormFieldWrapperProps<T = any> = Pick<
99
124
  FormFieldComponentProps,
100
- 'readonly' | 'label' | 'layout' | 'getStatus' | 'getValue'
125
+ 'readonly' | 'label' | 'layout' | 'getStatus' | 'getValue' | 'jsonPath' | 'format'
101
126
  > & {
102
127
  children?: (props: { value: T }) => ReactNode;
103
128
  };
104
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
+
105
156
  export const FormFieldWrapper = <T,>(props: FormFieldWrapperProps<T>) => {
106
- const { children, readonly, layout, label, getStatus, getValue } = props;
157
+ const { children, readonly, layout, label, jsonPath, format, getStatus, getValue } = props;
107
158
  const { status, error } = getStatus();
108
159
 
109
160
  const value = getValue();
@@ -111,14 +162,20 @@ export const FormFieldWrapper = <T,>(props: FormFieldWrapperProps<T>) => {
111
162
  return null;
112
163
  }
113
164
 
114
- const str = String(value ?? '');
165
+ const str = formatStaticValue(value, format);
115
166
 
116
167
  return (
117
- <div role='none' className='contents'>
168
+ <div className='contents'>
118
169
  <Input.Root validationValence={status}>
119
- {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
120
- {layout === 'static' ? <p>{str}</p> : children ? children({ value }) : null}
121
- {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 && (
122
179
  <Input.DescriptionAndValidation>
123
180
  <Input.Validation>{error}</Input.Validation>
124
181
  </Input.DescriptionAndValidation>
@@ -156,8 +213,8 @@ export class FormFieldErrorBoundary extends Component<FormFieldErrorBoundaryProp
156
213
  override render() {
157
214
  if (this.state.error) {
158
215
  return (
159
- <div className='flex gap-2 border border-rose-fill font-mono text-sm'>
160
- <span className='bg-rose-fill text-surface-text 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>
161
218
  {String(this.props.path?.join('.'))}
162
219
  </div>
163
220
  );
@@ -2,26 +2,31 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { forwardRef, useMemo } from 'react';
5
+ import * as Option from 'effect/Option';
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 { mx } from '@dxos/ui-theme';
10
- import { isTruthy } from '@dxos/util';
9
+ import { createJsonPath, type SchemaProperty } from '@dxos/effect';
11
10
 
12
11
  import { type FormHandlerProps } from '../../hooks';
13
12
  import { getFormProperties } from '../../util';
14
-
15
13
  import { useFormValues } from './Form';
16
14
  import { FormField, type FormFieldProps } from './FormField';
17
15
  import { FormFieldErrorBoundary, FormFieldLabel } from './FormFieldComponent';
16
+ import { DEFAULT_LAYOUT_NAME, FormLayout, FormLayoutAnnotation } from './Layout';
18
17
 
19
18
  const FORM_FIELDSET_NAME = 'Form.FieldSet';
20
19
 
21
20
  export type FormFieldSetProps<T extends AnyProperties> = {
22
21
  label?: string;
23
- exclude?: (props: SchemaProperty[]) => SchemaProperty[];
24
22
  sort?: string[];
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;
25
30
  } & Pick<FormHandlerProps<T>, 'schema'> &
26
31
  Pick<
27
32
  FormFieldProps,
@@ -32,11 +37,13 @@ export type FormFieldSetProps<T extends AnyProperties> = {
32
37
  | 'projection'
33
38
  | 'fieldMap'
34
39
  | 'fieldProvider'
40
+ | 'createTypename'
35
41
  | 'createOptionLabel'
36
42
  | 'createOptionIcon'
37
43
  | 'createInitialValuePath'
44
+ | 'createFieldMap'
38
45
  | 'db'
39
- | 'schemaHook'
46
+ | 'useType'
40
47
  | 'getOptions'
41
48
  | 'onCreate'
42
49
  >;
@@ -44,80 +51,123 @@ export type FormFieldSetProps<T extends AnyProperties> = {
44
51
  /**
45
52
  * Renders a set of form fields derived from a schema object.
46
53
  */
47
- export const FormFieldSet = forwardRef<HTMLDivElement, FormFieldSetProps<any>>(
48
- ({ label, schema, readonly, path, exclude, sort, projection, layout, ...props }, forwardRef) => {
49
- const values = useFormValues(FORM_FIELDSET_NAME, path);
50
-
51
- // TODO(burdon): Updates on every value change.
52
- // Remove values dep if can remove from getSchemaProperties.
53
- const properties = useMemo(() => {
54
- if (!schema) {
55
- return [];
56
- }
57
-
58
- // TODO(wittjosiah): Reconcile FormInputAnnotation with projection hidden properties & exclude function.
59
- const props = getFormProperties(schema.ast);
60
-
61
- // Use projection-based field management when view and projection are available.
62
- if (projection) {
63
- const fieldProjections = projection.getFieldProjections();
64
- const hiddenProperties = new Set(projection.getHiddenProperties());
65
-
66
- // Filter properties to only include visible ones and order by projection.
67
- const visibleProps = props.filter((prop) => !hiddenProperties.has(prop.name.toString()));
68
- const orderedProps: SchemaProperty[] = [];
69
-
70
- // Add properties in projection field order.
71
- for (const fieldProjection of fieldProjections) {
72
- const fieldPath = String(fieldProjection.field.path);
73
- const prop = visibleProps.find((p) => p.name === fieldPath);
74
- if (prop) {
75
- orderedProps.push(prop);
76
- }
77
- }
54
+ export const FormFieldSet = ({
55
+ label,
56
+ schema,
57
+ readonly,
58
+ path,
59
+ sort,
60
+ exclude,
61
+ projection,
62
+ layout,
63
+ layoutName = DEFAULT_LAYOUT_NAME,
64
+ ...props
65
+ }: FormFieldSetProps<any>) => {
66
+ const values = useFormValues(FORM_FIELDSET_NAME, path);
67
+ const properties = useFormFieldSetProperties({ schema, values, exclude, sort, projection });
68
+ if ((readonly || layout === 'static') && values == null) {
69
+ return null;
70
+ }
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
+
92
+ return (
93
+ <>
94
+ {layout !== 'inline' && label && <FormFieldLabel label={label} path={createJsonPath(path ?? [])} asChild />}
95
+ {properties.map((property) => {
96
+ const name = property.name.toString();
97
+ return (
98
+ <FormFieldErrorBoundary key={name} path={[...(path ?? []), name]}>
99
+ <FormField
100
+ type={property.type}
101
+ name={name}
102
+ path={[...(path ?? []), name]}
103
+ readonly={readonly}
104
+ layout={layout}
105
+ projection={projection}
106
+ {...props}
107
+ />
108
+ </FormFieldErrorBoundary>
109
+ );
110
+ })}
111
+ </>
112
+ );
113
+ };
78
114
 
79
- // Add any remaining properties not in projection.
80
- const projectionPaths = new Set(fieldProjections.map((projection) => String(projection.field.path)));
81
- const remainingProps = visibleProps.filter((prop) => !projectionPaths.has(prop.name.toString()));
82
- orderedProps.push(...remainingProps);
83
- return orderedProps;
84
- }
115
+ FormFieldSet.displayName = FORM_FIELDSET_NAME;
85
116
 
86
- // Fallback to legacy filter/sort behavior.
87
- const filtered = exclude ? exclude(props) : props;
88
- return sort
89
- ? filtered.sort(({ name: a }, { name: b }) => sort.indexOf(a.toString()) - sort.indexOf(b.toString()))
90
- : filtered;
91
- }, [schema, values, exclude, sort, projection?.fields]);
117
+ type UseFormFieldSetPropertiesParams = Pick<FormFieldSetProps<any>, 'schema' | 'exclude' | 'projection' | 'sort'> & {
118
+ values: AnyProperties | undefined;
119
+ sort?: string[];
120
+ };
92
121
 
93
- if ((readonly || layout === 'static') && values == null) {
94
- return null;
122
+ /**
123
+ * Resolves ordered schema properties for a field set (projection order, exclude, or sort).
124
+ */
125
+ const useFormFieldSetProperties = ({
126
+ schema,
127
+ exclude,
128
+ projection,
129
+ values,
130
+ sort,
131
+ }: UseFormFieldSetPropertiesParams): SchemaProperty[] => {
132
+ // TODO(burdon): Updates on every value change.
133
+ // Remove values dep if can remove from getSchemaProperties.
134
+ return useMemo(() => {
135
+ if (!schema) {
136
+ return [];
95
137
  }
96
138
 
97
- return (
98
- <div role='form' className={mx('w-full', layout === 'inline' && 'flex flex-col gap-2')} ref={forwardRef}>
99
- {layout !== 'inline' && label && <FormFieldLabel label={label} asChild />}
100
- {properties
101
- .map((property) => {
102
- const name = property.name.toString();
103
- return (
104
- <FormFieldErrorBoundary key={name} path={[...(path ?? []), name]}>
105
- <FormField
106
- type={property.type}
107
- name={name}
108
- path={[...(path ?? []), name]}
109
- readonly={readonly}
110
- layout={layout}
111
- projection={projection}
112
- {...props}
113
- />
114
- </FormFieldErrorBoundary>
115
- );
116
- })
117
- .filter(isTruthy)}
118
- </div>
119
- );
120
- },
121
- );
139
+ // TODO(wittjosiah): Reconcile FormInputAnnotation with projection hidden properties & exclude function.
140
+ const schemaProps = getFormProperties(schema.ast);
141
+ const filteredProps = exclude ? exclude(schemaProps) : schemaProps;
142
+
143
+ // Use projection-based field management when view and projection are available.
144
+ if (projection) {
145
+ const fieldProjections = projection.getFieldProjections();
146
+ const hiddenProperties = new Set(projection.getHiddenProperties());
147
+
148
+ // Filter properties to only include visible ones and order by projection.
149
+ const visibleProps = filteredProps.filter((prop) => !hiddenProperties.has(prop.name.toString()));
150
+ const orderedProps: SchemaProperty[] = [];
151
+
152
+ // Add properties in projection field order.
153
+ for (const fieldProjection of fieldProjections) {
154
+ const fieldPath = String(fieldProjection.field.path);
155
+ const prop = visibleProps.find((prop) => prop.name === fieldPath);
156
+ if (prop) {
157
+ orderedProps.push(prop);
158
+ }
159
+ }
122
160
 
123
- FormFieldSet.displayName = FORM_FIELDSET_NAME;
161
+ // Add any remaining properties not in projection.
162
+ const projectionPaths = new Set(fieldProjections.map((projection) => String(projection.field.path)));
163
+ const remainingProps = visibleProps.filter((prop) => !projectionPaths.has(prop.name.toString()));
164
+ orderedProps.push(...remainingProps);
165
+ return orderedProps;
166
+ }
167
+
168
+ // Fallback to legacy filter/sort behavior.
169
+ return sort
170
+ ? [...filteredProps].sort(({ name: a }, { name: b }) => sort.indexOf(a.toString()) - sort.indexOf(b.toString()))
171
+ : filteredProps;
172
+ }, [schema, values, exclude, sort, projection]);
173
+ };
@@ -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
+ });