@dxos/react-ui-form 0.8.4-main.3eb6e50203 → 0.8.4-main.3fbcb4aa9b

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 (153) hide show
  1. package/README.md +0 -1
  2. package/dist/lib/browser/index.mjs +1134 -682
  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 +1134 -682
  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 +3 -3
  13. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +63 -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 +44 -16
  17. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  18. package/dist/types/src/components/Form/Form.stories.d.ts +75 -73
  19. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  20. package/dist/types/src/components/Form/FormField.d.ts +11 -5
  21. package/dist/types/src/components/Form/FormField.d.ts.map +1 -1
  22. package/dist/types/src/components/Form/FormFieldComponent.d.ts +8 -7
  23. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
  24. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts +2 -2
  25. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -1
  26. package/dist/types/src/components/Form/FormFieldSet.d.ts +6 -10
  27. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/fields/ArrayField.d.ts +4 -4
  29. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
  30. package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -1
  31. package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -1
  32. package/dist/types/src/components/Form/fields/MarkdownField.d.ts +8 -1
  33. package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -1
  34. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +84 -0
  35. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -0
  36. package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -1
  37. package/dist/types/src/components/Form/fields/RefField.d.ts +15 -7
  38. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
  39. package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -1
  40. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -1
  41. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +63 -61
  42. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -1
  43. package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -1
  44. package/dist/types/src/components/Form/fields/TextField.d.ts +1 -1
  45. package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -1
  46. package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -1
  47. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +9 -0
  48. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -0
  49. package/dist/types/src/components/ObjectForm/index.d.ts +2 -0
  50. package/dist/types/src/components/ObjectForm/index.d.ts.map +1 -0
  51. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +26 -4
  52. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  53. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +63 -61
  54. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  55. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +9 -0
  56. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -0
  57. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +127 -0
  58. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -0
  59. package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts +2 -0
  60. package/dist/types/src/components/ObjectProperties/ObjectProperties.test.d.ts.map +1 -0
  61. package/dist/types/src/components/ObjectProperties/index.d.ts +2 -0
  62. package/dist/types/src/components/ObjectProperties/index.d.ts.map +1 -0
  63. package/dist/types/src/components/Settings/Settings.d.ts +28 -33
  64. package/dist/types/src/components/Settings/Settings.d.ts.map +1 -1
  65. package/dist/types/src/components/Settings/Settings.stories.d.ts +64 -61
  66. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -1
  67. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +43 -0
  68. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +1 -0
  69. package/dist/types/src/components/Settings/SettingsItem.d.ts +16 -0
  70. package/dist/types/src/components/Settings/SettingsItem.d.ts.map +1 -0
  71. package/dist/types/src/components/Settings/SettingsSelectField.d.ts +18 -0
  72. package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +1 -0
  73. package/dist/types/src/components/Settings/index.d.ts +2 -0
  74. package/dist/types/src/components/Settings/index.d.ts.map +1 -1
  75. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +6 -6
  76. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  77. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +67 -64
  78. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  79. package/dist/types/src/components/index.d.ts +2 -0
  80. package/dist/types/src/components/index.d.ts.map +1 -1
  81. package/dist/types/src/components/testing.d.ts +4 -4
  82. package/dist/types/src/components/testing.d.ts.map +1 -1
  83. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  84. package/dist/types/src/hooks/useKeyHandler.d.ts +2 -1
  85. package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -1
  86. package/dist/types/src/index.d.ts +0 -2
  87. package/dist/types/src/index.d.ts.map +1 -1
  88. package/dist/types/src/translations.d.ts +63 -61
  89. package/dist/types/src/translations.d.ts.map +1 -1
  90. package/dist/types/src/util/field-type.d.ts +19 -0
  91. package/dist/types/src/util/field-type.d.ts.map +1 -0
  92. package/dist/types/src/util/field.d.ts.map +1 -1
  93. package/dist/types/src/util/formatting.d.ts.map +1 -1
  94. package/dist/types/src/util/index.d.ts +1 -1
  95. package/dist/types/src/util/index.d.ts.map +1 -1
  96. package/dist/types/src/util/properties.d.ts.map +1 -1
  97. package/dist/types/src/util/refs.d.ts +1 -0
  98. package/dist/types/src/util/refs.d.ts.map +1 -1
  99. package/dist/types/tsconfig.tsbuildinfo +1 -1
  100. package/package.json +42 -42
  101. package/src/components/FieldEditor/FieldEditor.stories.tsx +6 -8
  102. package/src/components/FieldEditor/FieldEditor.test.tsx +0 -1
  103. package/src/components/FieldEditor/FieldEditor.tsx +6 -6
  104. package/src/components/Form/AUDIT.md +17 -0
  105. package/src/components/Form/Form.stories.tsx +29 -44
  106. package/src/components/Form/Form.tsx +101 -54
  107. package/src/components/Form/FormField.tsx +39 -19
  108. package/src/components/Form/FormFieldComponent.stories.tsx +4 -4
  109. package/src/components/Form/FormFieldComponent.tsx +11 -11
  110. package/src/components/Form/FormFieldSet.tsx +121 -104
  111. package/src/components/Form/fields/ArrayField.tsx +10 -9
  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 +85 -45
  116. package/src/components/Form/fields/SelectField.tsx +1 -1
  117. package/src/components/Form/fields/SelectOptionField.stories.tsx +10 -8
  118. package/src/components/Form/fields/SelectOptionField.tsx +13 -13
  119. package/src/components/Form/fields/TextField.tsx +2 -2
  120. package/src/components/Form/fields/TupleField.tsx +1 -1
  121. package/src/components/ObjectForm/ObjectForm.tsx +114 -0
  122. package/src/components/ObjectForm/index.ts +5 -0
  123. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +16 -18
  124. package/src/components/ObjectPicker/ObjectPicker.tsx +92 -65
  125. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +362 -0
  126. package/src/components/ObjectProperties/ObjectProperties.test.tsx +101 -0
  127. package/src/components/ObjectProperties/ObjectProperties.tsx +141 -0
  128. package/src/components/ObjectProperties/index.ts +5 -0
  129. package/src/components/Settings/AUDIT.md +52 -0
  130. package/src/components/Settings/Settings.stories.tsx +69 -21
  131. package/src/components/Settings/Settings.tsx +33 -187
  132. package/src/components/Settings/SettingsFieldSet.tsx +257 -0
  133. package/src/components/Settings/SettingsItem.tsx +58 -0
  134. package/src/components/Settings/SettingsSelectField.tsx +59 -0
  135. package/src/components/Settings/index.ts +2 -0
  136. package/src/components/ViewEditor/ViewEditor.stories.tsx +21 -19
  137. package/src/components/ViewEditor/ViewEditor.test.tsx +2 -2
  138. package/src/components/ViewEditor/ViewEditor.tsx +87 -50
  139. package/src/components/index.ts +2 -0
  140. package/src/components/testing.tsx +23 -12
  141. package/src/hooks/useFormHandler.ts +42 -24
  142. package/src/hooks/useKeyHandler.tsx +7 -7
  143. package/src/index.ts +0 -4
  144. package/src/translations.ts +66 -63
  145. package/src/util/field-type.ts +60 -0
  146. package/src/util/index.ts +1 -1
  147. package/src/util/refs.ts +7 -3
  148. package/dist/types/src/components/Form/Form-old.stories.d.ts +0 -131
  149. package/dist/types/src/components/Form/Form-old.stories.d.ts.map +0 -1
  150. package/dist/types/src/util/echo.d.ts +0 -6
  151. package/dist/types/src/util/echo.d.ts.map +0 -1
  152. package/src/components/Form/Form-old.stories.tsx +0 -314
  153. package/src/util/echo.ts +0 -25
@@ -2,126 +2,143 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { forwardRef, useMemo } from 'react';
5
+ import React, { useMemo } from 'react';
6
6
 
7
7
  import { type AnyProperties } from '@dxos/echo/internal';
8
8
  import { type SchemaProperty } from '@dxos/effect';
9
- import { type ThemedClassName } from '@dxos/react-ui';
10
- import { mx } from '@dxos/ui-theme';
11
- import { isTruthy } from '@dxos/util';
12
9
 
13
10
  import { type FormHandlerProps } from '../../hooks';
14
11
  import { getFormProperties } from '../../util';
15
-
16
12
  import { useFormValues } from './Form';
17
13
  import { FormField, type FormFieldProps } from './FormField';
18
14
  import { FormFieldErrorBoundary, FormFieldLabel } from './FormFieldComponent';
19
15
 
20
- export type FormFieldSetProps<T extends AnyProperties> = ThemedClassName<
21
- {
22
- label?: string;
23
- exclude?: (props: SchemaProperty[]) => SchemaProperty[];
24
- sort?: string[];
25
- } & Pick<FormHandlerProps<T>, 'schema'> &
26
- Pick<
27
- FormFieldProps,
28
- | 'path'
29
- | 'autoFocus'
30
- | 'readonly'
31
- | 'layout'
32
- | 'projection'
33
- | 'fieldMap'
34
- | 'fieldProvider'
35
- | 'createOptionLabel'
36
- | 'createOptionIcon'
37
- | 'createInitialValuePath'
38
- | 'db'
39
- | 'schemaHook'
40
- | 'getOptions'
41
- | 'onCreate'
42
- >
43
- >;
44
-
45
16
  const FORM_FIELDSET_NAME = 'Form.FieldSet';
46
17
 
47
- export const FormFieldSet = forwardRef<HTMLDivElement, FormFieldSetProps<any>>(
48
- ({ classNames, label, schema, readonly, path, exclude, sort, projection, layout, ...props }, forwardRef) => {
49
- const values = useFormValues(FORM_FIELDSET_NAME, path);
18
+ export type FormFieldSetProps<T extends AnyProperties> = {
19
+ label?: string;
20
+ sort?: string[];
21
+ exclude?: (props: SchemaProperty[]) => SchemaProperty[];
22
+ } & Pick<FormHandlerProps<T>, 'schema'> &
23
+ Pick<
24
+ FormFieldProps,
25
+ | 'path'
26
+ | 'autoFocus'
27
+ | 'readonly'
28
+ | 'layout'
29
+ | 'projection'
30
+ | 'fieldMap'
31
+ | 'fieldProvider'
32
+ | 'createTypename'
33
+ | 'createOptionLabel'
34
+ | 'createOptionIcon'
35
+ | 'createInitialValuePath'
36
+ | 'createFieldMap'
37
+ | 'db'
38
+ | 'useSchema'
39
+ | 'getOptions'
40
+ | 'onCreate'
41
+ >;
42
+
43
+ /**
44
+ * Renders a set of form fields derived from a schema object.
45
+ */
46
+ export const FormFieldSet = ({
47
+ label,
48
+ schema,
49
+ readonly,
50
+ path,
51
+ sort,
52
+ exclude,
53
+ projection,
54
+ layout,
55
+ ...props
56
+ }: FormFieldSetProps<any>) => {
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
+ }
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
+ };
50
85
 
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
- }
86
+ FormFieldSet.displayName = FORM_FIELDSET_NAME;
57
87
 
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
- }
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[] => {
103
+ // TODO(burdon): Updates on every value change.
104
+ // Remove values dep if can remove from getSchemaProperties.
105
+ return useMemo(() => {
106
+ if (!schema) {
107
+ return [];
108
+ }
78
109
 
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;
110
+ // TODO(wittjosiah): Reconcile FormInputAnnotation with projection hidden properties & exclude function.
111
+ const schemaProps = getFormProperties(schema.ast);
112
+ const filteredProps = exclude ? exclude(schemaProps) : schemaProps;
113
+
114
+ // Use projection-based field management when view and projection are available.
115
+ if (projection) {
116
+ const fieldProjections = projection.getFieldProjections();
117
+ const hiddenProperties = new Set(projection.getHiddenProperties());
118
+
119
+ // Filter properties to only include visible ones and order by projection.
120
+ const visibleProps = filteredProps.filter((prop) => !hiddenProperties.has(prop.name.toString()));
121
+ const orderedProps: SchemaProperty[] = [];
122
+
123
+ // Add properties in projection field order.
124
+ for (const fieldProjection of fieldProjections) {
125
+ const fieldPath = String(fieldProjection.field.path);
126
+ const prop = visibleProps.find((prop) => prop.name === fieldPath);
127
+ if (prop) {
128
+ orderedProps.push(prop);
129
+ }
84
130
  }
85
131
 
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]);
92
-
93
- if ((readonly || layout === 'static') && values == null) {
94
- return null;
132
+ // Add any remaining properties not in projection.
133
+ const projectionPaths = new Set(fieldProjections.map((projection) => String(projection.field.path)));
134
+ const remainingProps = visibleProps.filter((prop) => !projectionPaths.has(prop.name.toString()));
135
+ orderedProps.push(...remainingProps);
136
+ return orderedProps;
95
137
  }
96
138
 
97
- return (
98
- <div
99
- role='form'
100
- className={mx('is-full', layout === 'inline' && 'flex flex-col gap-2', classNames)}
101
- ref={forwardRef}
102
- >
103
- {layout !== 'inline' && label && <FormFieldLabel label={label} asChild />}
104
- {properties
105
- .map((property) => {
106
- const name = property.name.toString();
107
- return (
108
- <FormFieldErrorBoundary key={name} path={[...(path ?? []), name]}>
109
- <FormField
110
- type={property.type}
111
- name={name}
112
- path={[...(path ?? []), name]}
113
- readonly={readonly}
114
- layout={layout}
115
- projection={projection}
116
- {...props}
117
- />
118
- </FormFieldErrorBoundary>
119
- );
120
- })
121
- .filter(isTruthy)}
122
- </div>
123
- );
124
- },
125
- );
126
-
127
- FormFieldSet.displayName = FORM_FIELDSET_NAME;
139
+ // Fallback to legacy filter/sort behavior.
140
+ return sort
141
+ ? [...filteredProps].sort(({ name: a }, { name: b }) => sort.indexOf(a.toString()) - sort.indexOf(b.toString()))
142
+ : filteredProps;
143
+ }, [schema, values, exclude, sort, projection]);
144
+ };
@@ -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 bs-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 bs-[var(--line-height)]'>
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
  />
@@ -112,9 +113,9 @@ export const ArrayField = ({
112
113
  {/* TODO(burdon): Get label from schema. */}
113
114
  {!readonly && layout !== 'static' && (
114
115
  <IconButton
115
- classNames='flex is-full _mlb-cardSpacingBlock'
116
+ classNames='flex w-full'
116
117
  icon='ph--plus--regular'
117
- label={t('add field')}
118
+ label={t('add-item.button')}
118
119
  onClick={handleAdd}
119
120
  />
120
121
  )}
@@ -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
+ };