@dxos/react-ui-form 0.8.4-main.8360d9e660 → 0.8.4-main.8baae0fced

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 (147) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +1009 -612
  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 +82 -0
  6. package/dist/lib/browser/translations.mjs.map +7 -0
  7. package/dist/lib/node-esm/index.mjs +1009 -612
  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 +84 -0
  11. package/dist/lib/node-esm/translations.mjs.map +7 -0
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  13. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +63 -61
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/Form.d.ts +40 -12
  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 -74
  18. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/FormField.d.ts +11 -5
  20. package/dist/types/src/components/Form/FormField.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/FormFieldComponent.d.ts +8 -6
  22. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -1
  24. package/dist/types/src/components/Form/FormFieldSet.d.ts +5 -8
  25. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
  26. package/dist/types/src/components/Form/fields/ArrayField.d.ts +4 -4
  27. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -1
  29. package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -1
  30. package/dist/types/src/components/Form/fields/MarkdownField.d.ts +8 -1
  31. package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -1
  32. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +84 -0
  33. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -0
  34. package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -1
  35. package/dist/types/src/components/Form/fields/RefField.d.ts +12 -6
  36. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
  37. package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -1
  38. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -1
  39. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +63 -61
  40. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -1
  41. package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -1
  42. package/dist/types/src/components/Form/fields/TextField.d.ts +1 -1
  43. package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -1
  44. package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -1
  45. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +9 -0
  46. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -0
  47. package/dist/types/src/components/ObjectForm/index.d.ts +2 -0
  48. package/dist/types/src/components/ObjectForm/index.d.ts.map +1 -0
  49. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +26 -4
  50. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  51. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +63 -61
  52. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  53. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +9 -0
  54. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -0
  55. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +127 -0
  56. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -0
  57. package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts +2 -0
  58. package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts.map +1 -0
  59. package/dist/types/src/components/ObjectProperties/index.d.ts +2 -0
  60. package/dist/types/src/components/ObjectProperties/index.d.ts.map +1 -0
  61. package/dist/types/src/components/Settings/Settings.d.ts +28 -33
  62. package/dist/types/src/components/Settings/Settings.d.ts.map +1 -1
  63. package/dist/types/src/components/Settings/Settings.stories.d.ts +64 -61
  64. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -1
  65. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +43 -0
  66. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +1 -0
  67. package/dist/types/src/components/Settings/SettingsItem.d.ts +16 -0
  68. package/dist/types/src/components/Settings/SettingsItem.d.ts.map +1 -0
  69. package/dist/types/src/components/Settings/SettingsSelectField.d.ts +18 -0
  70. package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +1 -0
  71. package/dist/types/src/components/Settings/index.d.ts +2 -0
  72. package/dist/types/src/components/Settings/index.d.ts.map +1 -1
  73. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +3 -4
  74. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  75. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +65 -63
  76. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  77. package/dist/types/src/components/index.d.ts +2 -0
  78. package/dist/types/src/components/index.d.ts.map +1 -1
  79. package/dist/types/src/components/testing.d.ts +4 -4
  80. package/dist/types/src/components/testing.d.ts.map +1 -1
  81. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  82. package/dist/types/src/hooks/useKeyHandler.d.ts +2 -1
  83. package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -1
  84. package/dist/types/src/index.d.ts +0 -2
  85. package/dist/types/src/index.d.ts.map +1 -1
  86. package/dist/types/src/translations.d.ts +63 -61
  87. package/dist/types/src/translations.d.ts.map +1 -1
  88. package/dist/types/src/util/field-type.d.ts +19 -0
  89. package/dist/types/src/util/field-type.d.ts.map +1 -0
  90. package/dist/types/src/util/field.d.ts.map +1 -1
  91. package/dist/types/src/util/formatting.d.ts.map +1 -1
  92. package/dist/types/src/util/index.d.ts +1 -0
  93. package/dist/types/src/util/index.d.ts.map +1 -1
  94. package/dist/types/src/util/properties.d.ts +7 -1
  95. package/dist/types/src/util/properties.d.ts.map +1 -1
  96. package/dist/types/src/util/properties.test.d.ts +2 -0
  97. package/dist/types/src/util/properties.test.d.ts.map +1 -0
  98. package/dist/types/src/util/refs.d.ts +1 -0
  99. package/dist/types/src/util/refs.d.ts.map +1 -1
  100. package/dist/types/tsconfig.tsbuildinfo +1 -1
  101. package/package.json +43 -41
  102. package/src/components/FieldEditor/FieldEditor.stories.tsx +2 -2
  103. package/src/components/FieldEditor/FieldEditor.test.tsx +0 -1
  104. package/src/components/FieldEditor/FieldEditor.tsx +4 -3
  105. package/src/components/Form/AUDIT.md +17 -0
  106. package/src/components/Form/Form.stories.tsx +23 -36
  107. package/src/components/Form/Form.tsx +77 -48
  108. package/src/components/Form/FormField.tsx +27 -15
  109. package/src/components/Form/FormFieldComponent.tsx +6 -3
  110. package/src/components/Form/FormFieldSet.tsx +49 -32
  111. package/src/components/Form/fields/ArrayField.tsx +14 -8
  112. package/src/components/Form/fields/GeoPointField.tsx +8 -7
  113. package/src/components/Form/fields/MarkdownField.stories.tsx +206 -0
  114. package/src/components/Form/fields/MarkdownField.tsx +135 -45
  115. package/src/components/Form/fields/RefField.tsx +70 -35
  116. package/src/components/Form/fields/SelectOptionField.stories.tsx +8 -6
  117. package/src/components/Form/fields/SelectOptionField.tsx +12 -12
  118. package/src/components/Form/fields/TextField.tsx +2 -2
  119. package/src/components/Form/fields/TupleField.tsx +1 -1
  120. package/src/components/ObjectForm/ObjectForm.tsx +114 -0
  121. package/src/components/ObjectForm/index.ts +5 -0
  122. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +12 -10
  123. package/src/components/ObjectPicker/ObjectPicker.tsx +92 -65
  124. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +362 -0
  125. package/src/components/ObjectProperties/ObjectProperties.test.tsx +101 -0
  126. package/src/components/ObjectProperties/ObjectProperties.tsx +148 -0
  127. package/src/components/ObjectProperties/index.ts +5 -0
  128. package/src/components/Settings/AUDIT.md +51 -0
  129. package/src/components/Settings/Settings.stories.tsx +69 -21
  130. package/src/components/Settings/Settings.tsx +33 -183
  131. package/src/components/Settings/SettingsFieldSet.tsx +257 -0
  132. package/src/components/Settings/SettingsItem.tsx +58 -0
  133. package/src/components/Settings/SettingsSelectField.tsx +59 -0
  134. package/src/components/Settings/index.ts +2 -0
  135. package/src/components/ViewEditor/ViewEditor.stories.tsx +12 -13
  136. package/src/components/ViewEditor/ViewEditor.test.tsx +0 -1
  137. package/src/components/ViewEditor/ViewEditor.tsx +25 -32
  138. package/src/components/index.ts +2 -0
  139. package/src/components/testing.tsx +19 -11
  140. package/src/hooks/useKeyHandler.tsx +7 -7
  141. package/src/index.ts +0 -4
  142. package/src/translations.ts +66 -63
  143. package/src/util/field-type.ts +60 -0
  144. package/src/util/index.ts +1 -0
  145. package/src/util/properties.test.ts +63 -0
  146. package/src/util/properties.ts +14 -3
  147. package/src/util/refs.ts +7 -3
@@ -23,9 +23,9 @@ import {
23
23
  import { useTranslation } from '@dxos/react-ui';
24
24
  import { type ProjectionModel } from '@dxos/schema';
25
25
 
26
- import { translationKey } from '../../translations';
27
- import { getRefProps } from '../../util';
26
+ import { translationKey } from '#translations';
28
27
 
28
+ import { getRefProps } from '../../util';
29
29
  import {
30
30
  ArrayField,
31
31
  BooleanField,
@@ -78,14 +78,21 @@ export type FormFieldProps = {
78
78
  * Function to lookup custom renderers for specific properties.
79
79
  */
80
80
  fieldProvider?: FormFieldProvider;
81
+ /**
82
+ * Typename of the ref type that the create props apply to.
83
+ * When set, createOptionLabel/createOptionIcon/createInitialValuePath/createFieldMap/onCreate
84
+ * are only passed to ref fields whose typename matches.
85
+ */
86
+ createTypename?: string;
81
87
  } & Pick<FormFieldComponentProps, 'autoFocus' | 'readonly' | 'layout'> &
82
88
  Pick<
83
89
  RefFieldProps,
84
90
  | 'createOptionLabel'
85
91
  | 'createOptionIcon'
86
92
  | 'createInitialValuePath'
93
+ | 'createFieldMap'
87
94
  | 'db'
88
- | 'schemaHook'
95
+ | 'useSchema'
89
96
  | 'getOptions'
90
97
  | 'onCreate'
91
98
  >;
@@ -102,12 +109,14 @@ export const FormField = (props: FormFieldProps) => {
102
109
  layout,
103
110
 
104
111
  // RefFieldProps
112
+ createTypename,
105
113
  createOptionLabel,
106
114
  createOptionIcon,
107
115
  createInitialValuePath,
116
+ createFieldMap,
108
117
  db,
109
- schemaHook,
110
- getOptions: getRefOptions,
118
+ useSchema: schemaHook,
119
+ getOptions,
111
120
  onCreate,
112
121
  } = props;
113
122
  const { t } = useTranslation(translationKey);
@@ -117,7 +126,7 @@ export const FormField = (props: FormFieldProps) => {
117
126
 
118
127
  const label = useMemo(() => title ?? String.capitalize(name), [title, name]);
119
128
  const placeholder = useMemo(
120
- () => (examples?.length ? `${t('example placeholder')}: ${examples[0]}` : (description ?? label)),
129
+ () => (examples?.length ? `${t('example.placeholder')}: ${examples[0]}` : (description ?? label)),
121
130
  [examples, description, label],
122
131
  );
123
132
 
@@ -129,6 +138,7 @@ export const FormField = (props: FormFieldProps) => {
129
138
  label,
130
139
  placeholder,
131
140
  layout,
141
+ db,
132
142
  ...fieldState,
133
143
  };
134
144
 
@@ -169,7 +179,7 @@ export const FormField = (props: FormFieldProps) => {
169
179
  // Select field.
170
180
  //
171
181
 
172
- const options = getOptions(type);
182
+ const options = getSelectOptions(type);
173
183
  if (options) {
174
184
  // Resolve labels from projection metadata when available.
175
185
  const fieldProjections = projection?.getFieldProjections();
@@ -196,16 +206,18 @@ export const FormField = (props: FormFieldProps) => {
196
206
 
197
207
  const refProps = getRefProps(type);
198
208
  if (refProps) {
209
+ const isCreateTarget = !createTypename || refProps.typename === createTypename;
199
210
  return (
200
211
  <RefField
201
212
  {...fieldProps}
202
213
  {...refProps}
203
- createOptionLabel={createOptionLabel}
204
- createOptionIcon={createOptionIcon}
205
- createInitialValuePath={createInitialValuePath}
214
+ createOptionLabel={isCreateTarget ? createOptionLabel : undefined}
215
+ createOptionIcon={isCreateTarget ? createOptionIcon : undefined}
216
+ createInitialValuePath={isCreateTarget ? createInitialValuePath : undefined}
217
+ createFieldMap={isCreateTarget ? createFieldMap : undefined}
206
218
  db={db}
207
- schemaHook={schemaHook}
208
- getOptions={getRefOptions}
219
+ useSchema={schemaHook}
220
+ getOptions={getOptions}
209
221
  onCreate={onCreate}
210
222
  />
211
223
  );
@@ -237,8 +249,8 @@ export const FormField = (props: FormFieldProps) => {
237
249
  createOptionIcon={createOptionIcon}
238
250
  createInitialValuePath={createInitialValuePath}
239
251
  db={db}
240
- schemaHook={schemaHook}
241
- getOptions={getRefOptions}
252
+ useSchema={schemaHook}
253
+ getOptions={getOptions}
242
254
  onCreate={onCreate}
243
255
  />
244
256
  );
@@ -285,7 +297,7 @@ const getFormField = ({ type, format }: FormFieldComponentProps): FormFieldCompo
285
297
  }
286
298
  };
287
299
 
288
- const getOptions = (ast: SchemaAST.AST): Format.Options[] | undefined => {
300
+ const getSelectOptions = (ast: SchemaAST.AST): Format.Options[] | undefined => {
289
301
  if (isLiteralUnion(ast)) {
290
302
  return ast.types.map((type) => type.literal).filter((v): v is string | number => v !== null);
291
303
  }
@@ -13,6 +13,7 @@ import React, {
13
13
  type ReactNode,
14
14
  } from 'react';
15
15
 
16
+ import { type Database } from '@dxos/echo';
16
17
  import { type Format } from '@dxos/echo/internal';
17
18
  import { Icon, Input, Tooltip } from '@dxos/react-ui';
18
19
  import { inputTextLabel } from '@dxos/ui-theme';
@@ -50,6 +51,8 @@ export type FormFieldComponentProps<T = any> = {
50
51
  placeholder?: string;
51
52
  autoFocus?: boolean;
52
53
  layout?: Presentation;
54
+ /** Database the form is editing against; populated for fields whose value is an ECHO object/Ref. */
55
+ db?: Database.Database;
53
56
  } & FormFieldStateProps<T>;
54
57
 
55
58
  /**
@@ -77,7 +80,7 @@ export type FormFieldLabelProps = {
77
80
  export const FormFieldLabel = ({ label, error, readonly, asChild }: FormFieldLabelProps) => {
78
81
  const Label = readonly || asChild ? 'span' : Input.Label;
79
82
  return (
80
- <div role='none' className='flex items-center justify-between'>
83
+ <div className='flex items-center justify-between'>
81
84
  <Label className={inputTextLabel}>{label}</Label>
82
85
  {error && (
83
86
  <Tooltip.Trigger asChild content={error} side='bottom'>
@@ -113,7 +116,7 @@ export const FormFieldWrapper = <T,>(props: FormFieldWrapperProps<T>) => {
113
116
  const str = String(value ?? '');
114
117
 
115
118
  return (
116
- <div role='none' className='contents'>
119
+ <div className='contents'>
117
120
  <Input.Root validationValence={status}>
118
121
  {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
119
122
  {layout === 'static' ? <p>{str}</p> : children ? children({ value }) : null}
@@ -156,7 +159,7 @@ export class FormFieldErrorBoundary extends Component<FormFieldErrorBoundaryProp
156
159
  if (this.state.error) {
157
160
  return (
158
161
  <div className='flex gap-2 border border-rose-fill font-mono text-sm'>
159
- <span className='bg-rose-fill text-surface-text px-1 font-thin'>ERROR</span>
162
+ <span className='bg-rose-fill text-base-foreground px-1 font-thin'>ERROR</span>
160
163
  {String(this.props.path?.join('.'))}
161
164
  </div>
162
165
  );
@@ -9,7 +9,6 @@ import { type SchemaProperty } from '@dxos/effect';
9
9
 
10
10
  import { type FormHandlerProps } from '../../hooks';
11
11
  import { getFormProperties } from '../../util';
12
-
13
12
  import { useFormValues } from './Form';
14
13
  import { FormField, type FormFieldProps } from './FormField';
15
14
  import { FormFieldErrorBoundary, FormFieldLabel } from './FormFieldComponent';
@@ -30,11 +29,13 @@ export type FormFieldSetProps<T extends AnyProperties> = {
30
29
  | 'projection'
31
30
  | 'fieldMap'
32
31
  | 'fieldProvider'
32
+ | 'createTypename'
33
33
  | 'createOptionLabel'
34
34
  | 'createOptionIcon'
35
35
  | 'createInitialValuePath'
36
+ | 'createFieldMap'
36
37
  | 'db'
37
- | 'schemaHook'
38
+ | 'useSchema'
38
39
  | 'getOptions'
39
40
  | 'onCreate'
40
41
  >;
@@ -54,10 +55,54 @@ export const FormFieldSet = ({
54
55
  ...props
55
56
  }: FormFieldSetProps<any>) => {
56
57
  const values = useFormValues(FORM_FIELDSET_NAME, path);
58
+ const properties = useFormFieldSetProperties({ schema, values, exclude, sort, projection });
59
+ if ((readonly || layout === 'static') && values == null) {
60
+ return null;
61
+ }
57
62
 
63
+ return (
64
+ <>
65
+ {layout !== 'inline' && label && <FormFieldLabel label={label} asChild />}
66
+ {properties.map((property) => {
67
+ const name = property.name.toString();
68
+ return (
69
+ <FormFieldErrorBoundary key={name} path={[...(path ?? []), name]}>
70
+ <FormField
71
+ type={property.type}
72
+ name={name}
73
+ path={[...(path ?? []), name]}
74
+ readonly={readonly}
75
+ layout={layout}
76
+ projection={projection}
77
+ {...props}
78
+ />
79
+ </FormFieldErrorBoundary>
80
+ );
81
+ })}
82
+ </>
83
+ );
84
+ };
85
+
86
+ FormFieldSet.displayName = FORM_FIELDSET_NAME;
87
+
88
+ type UseFormFieldSetPropertiesParams = Pick<FormFieldSetProps<any>, 'schema' | 'exclude' | 'projection' | 'sort'> & {
89
+ values: AnyProperties | undefined;
90
+ sort?: string[];
91
+ };
92
+
93
+ /**
94
+ * Resolves ordered schema properties for a field set (projection order, exclude, or sort).
95
+ */
96
+ const useFormFieldSetProperties = ({
97
+ schema,
98
+ exclude,
99
+ projection,
100
+ values,
101
+ sort,
102
+ }: UseFormFieldSetPropertiesParams): SchemaProperty[] => {
58
103
  // TODO(burdon): Updates on every value change.
59
104
  // Remove values dep if can remove from getSchemaProperties.
60
- const properties = useMemo(() => {
105
+ return useMemo(() => {
61
106
  if (!schema) {
62
107
  return [];
63
108
  }
@@ -78,7 +123,7 @@ export const FormFieldSet = ({
78
123
  // Add properties in projection field order.
79
124
  for (const fieldProjection of fieldProjections) {
80
125
  const fieldPath = String(fieldProjection.field.path);
81
- const prop = visibleProps.find((p) => p.name === fieldPath);
126
+ const prop = visibleProps.find((prop) => prop.name === fieldPath);
82
127
  if (prop) {
83
128
  orderedProps.push(prop);
84
129
  }
@@ -96,32 +141,4 @@ export const FormFieldSet = ({
96
141
  ? [...filteredProps].sort(({ name: a }, { name: b }) => sort.indexOf(a.toString()) - sort.indexOf(b.toString()))
97
142
  : filteredProps;
98
143
  }, [schema, values, exclude, sort, projection]);
99
-
100
- if ((readonly || layout === 'static') && values == null) {
101
- return null;
102
- }
103
-
104
- return (
105
- <>
106
- {layout !== 'inline' && label && <FormFieldLabel label={label} asChild />}
107
- {properties.map((property) => {
108
- const name = property.name.toString();
109
- return (
110
- <FormFieldErrorBoundary key={name} path={[...(path ?? []), name]}>
111
- <FormField
112
- type={property.type}
113
- name={name}
114
- path={[...(path ?? []), name]}
115
- readonly={readonly}
116
- layout={layout}
117
- projection={projection}
118
- {...props}
119
- />
120
- </FormFieldErrorBoundary>
121
- );
122
- })}
123
- </>
124
- );
125
144
  };
126
-
127
- FormFieldSet.displayName = FORM_FIELDSET_NAME;
@@ -4,12 +4,13 @@
4
4
 
5
5
  import * as Option from 'effect/Option';
6
6
  import * as SchemaAST from 'effect/SchemaAST';
7
- import React, { Fragment, useCallback } from 'react';
7
+ import React, { useCallback } from 'react';
8
8
 
9
9
  import { findNode, getArrayElementType, getDiscriminatedType, isDiscriminatedUnion, isNestedType } from '@dxos/effect';
10
10
  import { IconButton, useTranslation } from '@dxos/react-ui';
11
11
 
12
- import { translationKey } from '../../../translations';
12
+ import { translationKey } from '#translations';
13
+
13
14
  import { getFormProperties } from '../../../util';
14
15
  import { useFormValues } from '../Form';
15
16
  import { FormField, type FormFieldProps } from '../FormField';
@@ -77,10 +78,10 @@ export const ArrayField = ({
77
78
  <FormFieldLabel readonly={readonly} label={label} asChild />
78
79
  )}
79
80
 
80
- <div role='none' className='flex flex-col gap-2'>
81
+ <div className='flex flex-col gap-form-gap'>
81
82
  {values?.map((_, index) => {
82
83
  return (
83
- <div role='none' key={index} className='grid grid-cols-[1fr_min-content] gap-2 last:mb-3'>
84
+ <div key={index} className='grid grid-cols-[1fr_min-content] gap-form-gap last:mb-form-gap items-center'>
84
85
  <FormField
85
86
  autoFocus={index === values.length - 1}
86
87
  type={elementType}
@@ -91,13 +92,13 @@ export const ArrayField = ({
91
92
  />
92
93
 
93
94
  {!readonly && layout !== 'static' && (
94
- <div role='none' className='flex flex-col h-full justify-end'>
95
+ <div className='flex flex-col h-full justify-end'>
95
96
  {/* NOTE: Aligns with center of last field if multi-field object. */}
96
- <div role='none' className='flex items-center h-[2rem]'>
97
+ <div className='flex items-center h-[2rem]'>
97
98
  <IconButton
98
99
  icon='ph--x--regular'
99
100
  iconOnly
100
- label={t('button remove')}
101
+ label={t('remove.button')}
101
102
  onClick={() => handleDelete(index)}
102
103
  classNames='self-center'
103
104
  />
@@ -111,7 +112,12 @@ export const ArrayField = ({
111
112
 
112
113
  {/* TODO(burdon): Get label from schema. */}
113
114
  {!readonly && layout !== 'static' && (
114
- <IconButton classNames='flex w-full' icon='ph--plus--regular' label={t('add field')} onClick={handleAdd} />
115
+ <IconButton
116
+ classNames='flex w-full'
117
+ icon='ph--plus--regular'
118
+ label={t('add-item.button')}
119
+ onClick={handleAdd}
120
+ />
115
121
  )}
116
122
  </>
117
123
  );
@@ -8,7 +8,8 @@ import { GeoLocation, type GeoPoint } from '@dxos/echo/internal';
8
8
  import { Input, useTranslation } from '@dxos/react-ui';
9
9
  import { safeParseFloat } from '@dxos/util';
10
10
 
11
- import { translationKey } from '../../../translations';
11
+ import { translationKey } from '#translations';
12
+
12
13
  import { type FormFieldComponentProps, FormFieldLabel } from '../FormFieldComponent';
13
14
 
14
15
  export const GeoPointField = ({
@@ -62,17 +63,17 @@ export const GeoPointField = ({
62
63
  {layout === 'static' ? (
63
64
  <LatLng {...value} />
64
65
  ) : (
65
- <div role='none' className='grid grid-cols-2 gap-2'>
66
+ <div className='grid grid-cols-2 gap-form-gap'>
66
67
  <div>
67
68
  <Input.Root>
68
- {layout !== 'inline' && <Input.Label>{t('latitude label')}</Input.Label>}
69
+ {layout !== 'inline' && <Input.Label>{t('latitude.label')}</Input.Label>}
69
70
  <Input.TextInput
70
71
  type='number'
71
72
  step='0.00001'
72
73
  min='-90'
73
74
  max='90'
74
75
  disabled={!!readonly}
75
- placeholder={t('latitude placeholder')}
76
+ placeholder={t('latitude.placeholder')}
76
77
  value={latitudeText ?? ''}
77
78
  onChange={handleChange('latitude', setLatitudeText)}
78
79
  onBlur={onBlur}
@@ -81,14 +82,14 @@ export const GeoPointField = ({
81
82
  </div>
82
83
  <div>
83
84
  <Input.Root>
84
- {layout !== 'inline' && <Input.Label>{t('longitude label')}</Input.Label>}
85
+ {layout !== 'inline' && <Input.Label>{t('longitude.label')}</Input.Label>}
85
86
  <Input.TextInput
86
87
  type='number'
87
88
  step='0.00001'
88
89
  min='-180'
89
90
  max='180'
90
91
  disabled={!!readonly}
91
- placeholder={t('longitude placeholder')}
92
+ placeholder={t('longitude.placeholder')}
92
93
  value={longitudeText ?? ''}
93
94
  onChange={handleChange('longitude', setLongitudeText)}
94
95
  onBlur={onBlur}
@@ -106,7 +107,7 @@ const LatLng = ({ latitude = 0, longitude = 0 }: GeoLocation) => {
106
107
  const lngHem = longitude >= 0 ? 'E' : 'W';
107
108
 
108
109
  return (
109
- <span className='inline-flex items-center gap-1'>
110
+ <span className='inline-flex items-center gap-form-gap'>
110
111
  <span>
111
112
  <span>{Math.abs(latitude).toFixed(5)}</span>
112
113
  <span className='text-subdued'>°{latHem}</span>
@@ -0,0 +1,206 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import * as Schema from 'effect/Schema';
7
+ import React, { useMemo } from 'react';
8
+ import { expect, userEvent } from 'storybook/test';
9
+
10
+ import { Filter, Format, Obj, Ref } from '@dxos/echo';
11
+ import { useQuery } from '@dxos/react-client/echo';
12
+ import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
13
+ import { Panel } from '@dxos/react-ui';
14
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
15
+ import { Text } from '@dxos/schema';
16
+
17
+ import { translations } from '#translations';
18
+
19
+ import { TestLayout } from '../../testing';
20
+ import { Form } from '../Form';
21
+
22
+ //
23
+ // String-backed markdown.
24
+ //
25
+
26
+ const StringSchema = Schema.Struct({
27
+ notes: Schema.String.pipe(Format.FormatAnnotation.set(Format.TypeFormat.Markdown)).annotations({ title: 'Notes' }),
28
+ });
29
+
30
+ const StringStory = () => (
31
+ <TestLayout>
32
+ <Form.Root schema={StringSchema} defaultValues={{ notes: '# Hello\n\nMarkdown text *here*.' }}>
33
+ <Form.Viewport>
34
+ <Form.Content>
35
+ <Form.FieldSet />
36
+ </Form.Content>
37
+ </Form.Viewport>
38
+ </Form.Root>
39
+ </TestLayout>
40
+ );
41
+
42
+ //
43
+ // Ref<Text>-backed markdown.
44
+ //
45
+
46
+ const TextNoteHolder = Schema.Struct({
47
+ instructions: Ref.Ref(Text.Text)
48
+ .pipe(Format.FormatAnnotation.set(Format.TypeFormat.Markdown))
49
+ .annotations({ title: 'Instructions' }),
50
+ });
51
+
52
+ const RefStory = () => {
53
+ const { space } = useClientStory();
54
+ const [text] = useQuery(space?.db, Filter.type(Text.Text));
55
+ const values = useMemo(() => (text ? { instructions: Ref.make(text) } : undefined), [text]);
56
+
57
+ if (!space || !values) {
58
+ return null;
59
+ }
60
+
61
+ return (
62
+ <TestLayout>
63
+ <Panel.Root>
64
+ <Panel.Content asChild>
65
+ <Form.Root schema={TextNoteHolder} values={values as any} db={space.db}>
66
+ <Form.Viewport>
67
+ <Form.Content>
68
+ <Form.FieldSet />
69
+ </Form.Content>
70
+ </Form.Viewport>
71
+ </Form.Root>
72
+ </Panel.Content>
73
+ </Panel.Root>
74
+ </TestLayout>
75
+ );
76
+ };
77
+
78
+ //
79
+ // Ref<Text> empty — shows the create button.
80
+ //
81
+
82
+ const EmptyRefSchema = Schema.Struct({
83
+ instructions: Schema.optional(
84
+ Ref.Ref(Text.Text)
85
+ .pipe(Format.FormatAnnotation.set(Format.TypeFormat.Markdown))
86
+ .annotations({ title: 'Instructions' }),
87
+ ),
88
+ });
89
+
90
+ const EmptyRefStory = () => {
91
+ const { space } = useClientStory();
92
+ if (!space) {
93
+ return null;
94
+ }
95
+
96
+ return (
97
+ <TestLayout>
98
+ <Panel.Root>
99
+ <Panel.Content asChild>
100
+ <Form.Root schema={EmptyRefSchema} defaultValues={{}} db={space.db}>
101
+ <Form.Viewport>
102
+ <Form.Content>
103
+ <Form.FieldSet />
104
+ </Form.Content>
105
+ </Form.Viewport>
106
+ </Form.Root>
107
+ </Panel.Content>
108
+ </Panel.Root>
109
+ </TestLayout>
110
+ );
111
+ };
112
+
113
+ const meta = {
114
+ title: 'ui/react-ui-form/MarkdownField',
115
+ decorators: [withTheme(), withLayout({ layout: 'column' })],
116
+ parameters: {
117
+ layout: 'fullscreen',
118
+ translations,
119
+ },
120
+ } satisfies Meta;
121
+
122
+ export default meta;
123
+
124
+ type Story = StoryObj<typeof meta>;
125
+
126
+ export const StringBacked: Story = {
127
+ render: () => <StringStory />,
128
+ };
129
+
130
+ // Regression test: keystrokes into a string-backed markdown field must not
131
+ // blur the editor. Previously the EditorView was destroyed and recreated on
132
+ // every change because `onChange` was a useTextEditor dep and form callbacks
133
+ // are unstable across renders.
134
+ export const StringBackedKeepsFocus: Story = {
135
+ render: () => (
136
+ <TestLayout>
137
+ <Form.Root schema={StringSchema} defaultValues={{ notes: '' }}>
138
+ <Form.Viewport>
139
+ <Form.Content>
140
+ <Form.FieldSet />
141
+ </Form.Content>
142
+ </Form.Viewport>
143
+ </Form.Root>
144
+ </TestLayout>
145
+ ),
146
+ play: async ({ canvasElement }) => {
147
+ const waitFor = async <T,>(predicate: () => T | null | undefined, message: string): Promise<T> => {
148
+ for (let attempt = 0; attempt < 50; attempt++) {
149
+ const value = predicate();
150
+ if (value) {
151
+ return value;
152
+ }
153
+ await new Promise((resolve) => setTimeout(resolve, 100));
154
+ }
155
+ throw new Error(message);
156
+ };
157
+
158
+ const editorRoot = await waitFor(
159
+ () => canvasElement.querySelector<HTMLElement>('.cm-editor'),
160
+ 'CodeMirror editor root (.cm-editor) not found',
161
+ );
162
+ const editorContent = editorRoot.querySelector<HTMLElement>('.cm-content')!;
163
+ await expect(editorContent).toBeVisible();
164
+
165
+ // Focus editor and wait for focus to settle inside the editor root.
166
+ editorContent.focus();
167
+ await waitFor(() => (editorRoot.contains(document.activeElement) ? true : null), 'Editor did not receive focus');
168
+
169
+ // Type several characters; focus must be preserved inside the editor root
170
+ // after every keystroke. Regression: previously the EditorView was
171
+ // destroyed and recreated on every change, which detached the focused
172
+ // contentDOM and blurred the input.
173
+ const phrase = 'hello world';
174
+ for (const ch of phrase) {
175
+ await userEvent.keyboard(ch);
176
+ await expect(editorRoot.contains(document.activeElement)).toBe(true);
177
+ }
178
+
179
+ await expect(editorContent).toHaveTextContent(phrase);
180
+ },
181
+ };
182
+
183
+ export const RefPopulated: Story = {
184
+ render: () => <RefStory />,
185
+ decorators: [
186
+ withClientProvider({
187
+ createIdentity: true,
188
+ createSpace: true,
189
+ types: [Text.Text],
190
+ onCreateSpace: async ({ space }) => {
191
+ space.db.add(Obj.make(Text.Text, { content: '# Magazine brief\n\nLong-form text describing what to gather.' }));
192
+ },
193
+ }),
194
+ ],
195
+ };
196
+
197
+ export const RefEmpty: Story = {
198
+ render: () => <EmptyRefStory />,
199
+ decorators: [
200
+ withClientProvider({
201
+ createIdentity: true,
202
+ createSpace: true,
203
+ types: [Text.Text],
204
+ }),
205
+ ],
206
+ };