@dxos/react-ui-form 0.8.4-main.74a063c4e0 → 0.8.4-main.765dc60934

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 (134) hide show
  1. package/LICENSE +102 -5
  2. package/dist/lib/browser/index.mjs +873 -446
  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 +873 -446
  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 +34 -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 +73 -71
  18. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/FormField.d.ts +5 -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 +10 -4
  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 +17 -3
  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 +24 -12
  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 +63 -61
  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.map +1 -1
  83. package/dist/types/src/index.d.ts +0 -2
  84. package/dist/types/src/index.d.ts.map +1 -1
  85. package/dist/types/src/translations.d.ts +63 -61
  86. package/dist/types/src/translations.d.ts.map +1 -1
  87. package/dist/types/src/util/field-type.d.ts +19 -0
  88. package/dist/types/src/util/field-type.d.ts.map +1 -0
  89. package/dist/types/src/util/field.d.ts.map +1 -1
  90. package/dist/types/src/util/formatting.d.ts.map +1 -1
  91. package/dist/types/src/util/index.d.ts +1 -0
  92. package/dist/types/src/util/index.d.ts.map +1 -1
  93. package/dist/types/src/util/properties.d.ts.map +1 -1
  94. package/dist/types/src/util/refs.d.ts.map +1 -1
  95. package/dist/types/tsconfig.tsbuildinfo +1 -1
  96. package/package.json +40 -38
  97. package/src/components/FieldEditor/FieldEditor.stories.tsx +2 -1
  98. package/src/components/FieldEditor/FieldEditor.tsx +3 -2
  99. package/src/components/Form/Form.stories.tsx +9 -19
  100. package/src/components/Form/Form.tsx +59 -7
  101. package/src/components/Form/FormField.tsx +12 -10
  102. package/src/components/Form/FormFieldComponent.tsx +6 -3
  103. package/src/components/Form/FormFieldSet.tsx +47 -31
  104. package/src/components/Form/fields/ArrayField.tsx +7 -6
  105. package/src/components/Form/fields/GeoPointField.tsx +3 -2
  106. package/src/components/Form/fields/MarkdownField.stories.tsx +206 -0
  107. package/src/components/Form/fields/MarkdownField.tsx +135 -45
  108. package/src/components/Form/fields/RefField.tsx +61 -30
  109. package/src/components/Form/fields/SelectOptionField.stories.tsx +8 -5
  110. package/src/components/Form/fields/SelectOptionField.tsx +6 -5
  111. package/src/components/Form/fields/TextField.tsx +2 -2
  112. package/src/components/ObjectForm/ObjectForm.tsx +114 -0
  113. package/src/components/ObjectForm/index.ts +5 -0
  114. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +9 -6
  115. package/src/components/ObjectPicker/ObjectPicker.tsx +28 -12
  116. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +362 -0
  117. package/src/components/ObjectProperties/ObjectProperties.test.tsx +101 -0
  118. package/src/components/ObjectProperties/ObjectProperties.tsx +141 -0
  119. package/src/components/ObjectProperties/index.ts +5 -0
  120. package/src/components/Settings/AUDIT.md +52 -0
  121. package/src/components/Settings/Settings.stories.tsx +50 -1
  122. package/src/components/Settings/Settings.tsx +7 -56
  123. package/src/components/Settings/SettingsFieldSet.tsx +257 -0
  124. package/src/components/Settings/SettingsItem.tsx +58 -0
  125. package/src/components/Settings/SettingsSelectField.tsx +59 -0
  126. package/src/components/Settings/index.ts +2 -0
  127. package/src/components/ViewEditor/ViewEditor.stories.tsx +10 -10
  128. package/src/components/ViewEditor/ViewEditor.tsx +19 -26
  129. package/src/components/index.ts +2 -0
  130. package/src/components/testing.tsx +20 -21
  131. package/src/index.ts +0 -4
  132. package/src/translations.ts +7 -3
  133. package/src/util/field-type.ts +60 -0
  134. package/src/util/index.ts +1 -0
@@ -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,7 +63,7 @@ export const GeoPointField = ({
62
63
  {layout === 'static' ? (
63
64
  <LatLng {...value} />
64
65
  ) : (
65
- <div role='none' className='grid grid-cols-2 gap-form-gap'>
66
+ <div className='grid grid-cols-2 gap-form-gap'>
66
67
  <div>
67
68
  <Input.Root>
68
69
  {layout !== 'inline' && <Input.Label>{t('latitude.label')}</Input.Label>}
@@ -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
+ };
@@ -2,77 +2,167 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import React, { useCallback, useEffect, useRef } from 'react';
5
+ import { useAtomValue } from '@effect-atom/atom-react';
6
+ import React, { useCallback, useMemo } from 'react';
6
7
 
7
- import { Input, type TextAreaProps } from '@dxos/react-ui';
8
+ import { type Database, Obj, Ref } from '@dxos/echo';
9
+ import { AtomRef } from '@dxos/echo-atom';
10
+ import { createDocAccessor } from '@dxos/echo-db';
11
+ import { Button, Icon, Input, useTranslation } from '@dxos/react-ui';
12
+ import { Editor, useBasicMarkdownExtensions } from '@dxos/react-ui-editor';
13
+ import { Text } from '@dxos/schema';
14
+ import { createDataExtensions } from '@dxos/ui-editor';
15
+
16
+ import { translationKey } from '#translations';
8
17
 
9
18
  import { type FormFieldComponentProps, FormFieldLabel } from '../FormFieldComponent';
10
19
 
20
+ const editorClassNames =
21
+ 'transition-colors bg-input-surface focus-within:bg-focus-surface border border-separator rounded-xs p-1 px-2';
22
+
23
+ /**
24
+ * Form field that edits a markdown value in a CodeMirror editor.
25
+ * Supports two backings:
26
+ * - `Schema.String` annotated with `Format.TypeFormat.Markdown`: edited as plain text via `getValue`/`onValueChange`.
27
+ * - `Ref<Text>` annotated with `Format.TypeFormat.Markdown`: edited in-place via an Automerge doc accessor.
28
+ * When the ref is empty and a database is available, a create button is shown to allocate a new Text.
29
+ */
11
30
  export const MarkdownField = ({
12
31
  type,
13
32
  readonly,
14
33
  label,
15
34
  placeholder,
16
35
  layout,
36
+ db,
17
37
  getStatus,
18
38
  getValue,
19
39
  onValueChange,
20
- onBlur,
21
40
  }: FormFieldComponentProps) => {
22
41
  const { status, error } = getStatus();
23
- const textareaRef = useRef<HTMLTextAreaElement>(null);
42
+ const isRef = Ref.isRefType(type);
43
+ const value = getValue();
24
44
 
25
- // TODO(ZaymonFC): If we start using `Enter` for form submission, we should prevent default behavior here
26
- // since we're using a textarea element and the user needs to be able to enter new lines.
45
+ if ((readonly || layout === 'static') && value == null) {
46
+ return null;
47
+ }
27
48
 
28
- const adjustHeight = useCallback(() => {
29
- const textarea = textareaRef.current;
30
- if (textarea) {
31
- textarea.style.blockSize = 'auto'; // For measurement.
32
- textarea.style.blockSize = `${textarea.scrollHeight + 2}px`;
33
- }
34
- }, []);
49
+ const renderEditor = () => {
50
+ if (isRef) {
51
+ const reference = value as Ref.Ref<any> | undefined;
52
+ if (reference) {
53
+ return <RefMarkdownEditor reference={reference} placeholder={placeholder} />;
54
+ }
35
55
 
36
- // Adjust height on initial render.
37
- useEffect(() => {
38
- adjustHeight();
39
- }, [adjustHeight]);
56
+ if (readonly) {
57
+ return null;
58
+ }
40
59
 
41
- // Adjust height when the content changes.
42
- useEffect(() => {
43
- adjustHeight();
44
- }, [getValue(), adjustHeight]);
60
+ return <CreateTextButton db={db} disabled={!db} onCreate={(ref) => onValueChange(type, ref)} />;
61
+ }
45
62
 
46
- const handleChange = useCallback<NonNullable<TextAreaProps['onChange']>>(
47
- (event) => onValueChange(type, event.target.value),
48
- [type, onValueChange],
49
- );
63
+ return (
64
+ <StringMarkdownEditor
65
+ value={(value as string | undefined) ?? ''}
66
+ placeholder={placeholder}
67
+ readonly={!!readonly}
68
+ onChange={(next) => onValueChange(type, next)}
69
+ />
70
+ );
71
+ };
50
72
 
51
- const value = getValue();
52
- if ((readonly || layout === 'static') && !value) {
53
- return null;
54
- }
73
+ const renderStatic = () => {
74
+ if (isRef) {
75
+ const reference = value as Ref.Ref<any> | undefined;
76
+ return reference ? <RefStaticText reference={reference} /> : null;
77
+ }
78
+ return <p className='whitespace-pre-wrap'>{(value as string | undefined) ?? ''}</p>;
79
+ };
55
80
 
56
- // TODO(burdon): Use Markdown Editor.
57
81
  return (
58
82
  <Input.Root validationValence={status}>
59
83
  {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
60
- {layout === 'static' ? (
61
- <p>{value}</p>
62
- ) : (
63
- <Input.TextArea
64
- ref={textareaRef}
65
- disabled={!!readonly}
66
- placeholder={placeholder}
67
- value={value ?? ''}
68
- classNames={'min-h-auto max-h-40 overflow-auto'}
69
- onChange={handleChange}
70
- onBlur={onBlur}
71
- style={{ resize: 'none' }}
72
- spellCheck={false}
73
- />
74
- )}
84
+ {layout === 'static' ? renderStatic() : renderEditor()}
75
85
  {layout === 'full' && <Input.Validation>{error}</Input.Validation>}
76
86
  </Input.Root>
77
87
  );
78
88
  };
89
+
90
+ /** Read-only static rendering for a `Ref<Text>` value: resolve the ref and print its content. */
91
+ const RefStaticText = ({ reference }: { reference: Ref.Ref<any> }) => {
92
+ const text = useAtomValue(useMemo(() => AtomRef.make(reference), [reference]));
93
+ if (!text?.content) {
94
+ return null;
95
+ }
96
+ return <p className='whitespace-pre-wrap'>{text.content}</p>;
97
+ };
98
+
99
+ type RefMarkdownEditorProps = {
100
+ reference: Ref.Ref<any>;
101
+ placeholder?: string;
102
+ };
103
+
104
+ const RefMarkdownEditor = ({ reference, placeholder }: RefMarkdownEditorProps) => {
105
+ const text = useAtomValue(useMemo(() => AtomRef.make(reference), [reference]));
106
+ const dataExtensions = useMemo(
107
+ () =>
108
+ text ? [createDataExtensions({ id: reference.dxn.toString(), text: createDocAccessor(text, ['content']) })] : [],
109
+ [text, reference],
110
+ );
111
+ const extensions = useBasicMarkdownExtensions({ placeholder, extensions: dataExtensions });
112
+ if (!text) {
113
+ return null;
114
+ }
115
+
116
+ return (
117
+ <Editor.Root>
118
+ <Editor.View classNames={editorClassNames} extensions={extensions} />
119
+ </Editor.Root>
120
+ );
121
+ };
122
+
123
+ type StringMarkdownEditorProps = {
124
+ value: string;
125
+ placeholder?: string;
126
+ readonly?: boolean;
127
+ onChange: (value: string) => void;
128
+ };
129
+
130
+ const StringMarkdownEditor = ({ value, placeholder, readonly, onChange }: StringMarkdownEditorProps) => {
131
+ const extensions = useBasicMarkdownExtensions({ placeholder });
132
+ const handleChange = useCallback((next: string) => onChange(next), [onChange]);
133
+
134
+ return (
135
+ <Editor.Root>
136
+ <Editor.View
137
+ classNames={editorClassNames}
138
+ extensions={extensions}
139
+ value={value}
140
+ onChange={readonly ? undefined : handleChange}
141
+ />
142
+ </Editor.Root>
143
+ );
144
+ };
145
+
146
+ type CreateTextButtonProps = {
147
+ db?: Database.Database;
148
+ disabled?: boolean;
149
+ onCreate: (ref: Ref.Ref<Text.Text>) => void;
150
+ };
151
+
152
+ const CreateTextButton = ({ db, disabled, onCreate }: CreateTextButtonProps) => {
153
+ const { t } = useTranslation(translationKey);
154
+ const handleClick = useCallback(() => {
155
+ if (!db) {
156
+ return;
157
+ }
158
+ const text = db.add(Obj.make(Text.Text, { content: '' }));
159
+ onCreate(Ref.make(text));
160
+ }, [db, onCreate]);
161
+
162
+ return (
163
+ <Button onClick={handleClick} disabled={disabled}>
164
+ <Icon icon='ph--plus--regular' size={4} />
165
+ <span>{t('create-text.label')}</span>
166
+ </Button>
167
+ );
168
+ };
@@ -2,22 +2,22 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { useCallback, useMemo } from 'react';
5
+ import React, { useCallback, useMemo, useState } from 'react';
6
6
 
7
7
  import '@dxos/lit-ui/dx-tag-picker.pcss';
8
8
  import { type Database, Entity, Filter, Obj, Ref, Type } from '@dxos/echo';
9
- import { useQuery, useSchema as useSchema$ } from '@dxos/echo-react';
9
+ import { useQuery, useSchema as defaultUseSchema } from '@dxos/echo-react';
10
10
  import { ReferenceAnnotationId, type ReferenceAnnotationValue } from '@dxos/echo/internal';
11
11
  import { findAnnotation } from '@dxos/effect';
12
12
  import { DXN } from '@dxos/keys';
13
13
  import { DxAnchor } from '@dxos/lit-ui/react';
14
14
  import { Button, Icon, Input, useTranslation } from '@dxos/react-ui';
15
15
  import { ParentLabelAnnotationId } from '@dxos/schema';
16
- import { mx } from '@dxos/ui-theme';
17
16
 
18
- import { translationKey } from '../../../translations';
17
+ import { translationKey } from '#translations';
18
+
19
19
  import { ObjectPicker, type ObjectPickerContentProps, type RefOption } from '../../ObjectPicker';
20
- import { omitId } from '../Form';
20
+ import { omitHiddenFormFields, omitId } from '../Form';
21
21
  import { type FormFieldComponentProps, FormFieldLabel } from '../FormFieldComponent';
22
22
 
23
23
  // TODO(burdon): Factor out.
@@ -33,7 +33,7 @@ const defaultGetOptions: NonNullable<RefFieldProps['getOptions']> = (results, {
33
33
  return { id, label: label ?? id };
34
34
  });
35
35
 
36
- const defaultResultsHook: NonNullable<RefFieldProps['resultsHook']> = (db, typename) =>
36
+ const defaultUseResults: NonNullable<RefFieldProps['useResults']> = (db, typename) =>
37
37
  useQuery(
38
38
  db,
39
39
  typename
@@ -50,10 +50,17 @@ export type RefFieldProps = FormFieldComponentProps &
50
50
  'createOptionLabel' | 'createOptionIcon' | 'createInitialValuePath' | 'createFieldMap'
51
51
  > & {
52
52
  db?: Database.Database;
53
- resultsHook?: (db?: Database.Database, typename?: string) => Entity.Any[];
54
- schemaHook?: (db?: Database.Database, typename?: string) => Type.AnyEntity;
53
+ // TODO(burdon): Replace hooks with callbacks.
54
+ useSchema?: (db?: Database.Database, typename?: string) => Type.AnyEntity;
55
+ useResults?: (db?: Database.Database, typename?: string) => Entity.Any[];
55
56
  getOptions?: (objects: Entity.Any[], options?: { parentLabel?: boolean }) => RefOption[];
56
- onCreate?: (schema: Type.AnyEntity, values: any) => void;
57
+ /**
58
+ * Persist a newly-created object. Called after the user fills out the
59
+ * inline create form and clicks Save. Should add the object to the
60
+ * database and return it (sync or async). The returned object is then
61
+ * wired into this slot's form value as a Ref.
62
+ */
63
+ onCreate?: (schema: Type.AnyEntity, values: any) => Obj.Unknown | Promise<Obj.Unknown> | undefined | void;
57
64
  };
58
65
 
59
66
  export const RefField = (props: RefFieldProps) => {
@@ -70,8 +77,8 @@ export const RefField = (props: RefFieldProps) => {
70
77
  createInitialValuePath,
71
78
  createFieldMap,
72
79
  db,
73
- resultsHook: useResults = defaultResultsHook,
74
- schemaHook: useSchema = useSchema$,
80
+ useSchema = defaultUseSchema,
81
+ useResults = defaultUseResults,
75
82
  getOptions = defaultGetOptions,
76
83
  onCreate,
77
84
  onValueChange,
@@ -92,42 +99,63 @@ export const RefField = (props: RefFieldProps) => {
92
99
 
93
100
  const handleGetValue = useCallback(() => {
94
101
  const formValue = getValue();
102
+ // Match form-value Refs against options by the bare object id (last DXN
103
+ // segment), not by full DXN string. Ref.make uses
104
+ // `DXN.fromLocalObjectId(id)` (`dxn:echo:@:<id>`), but
105
+ // `Entity.getDXN(obj)` on a registered object produces the space-scoped
106
+ // form (`dxn:echo:<spaceId>:<id>`). String-comparing the two never
107
+ // matches, so the just-created Ref's option lookup fails and the slot
108
+ // displays as empty even though the underlying form value IS set.
109
+ const dxnToObjectId = (dxn: string): string => dxn.split(':').pop() ?? dxn;
95
110
 
96
111
  const unknownToRefOption = (value: unknown) => {
97
112
  const isRef = Ref.isRef(value);
98
113
  if (isRef || isRefSnapshot(value)) {
99
114
  const dxnString = isRef ? value.dxn.toString() : value['/'];
100
- const matchingOption = options.find((option) => option.id === dxnString);
115
+ const objectId = dxnToObjectId(dxnString);
116
+ const matchingOption = options.find((option) => dxnToObjectId(option.id) === objectId);
101
117
  if (matchingOption) {
102
118
  return matchingOption;
103
119
  }
104
120
  }
121
+
105
122
  return undefined;
106
123
  };
107
124
 
108
125
  return unknownToRefOption(formValue);
109
126
  }, [options, getValue]);
110
127
 
111
- const handleUpdate = useCallback(
112
- (id: string | undefined) => {
113
- const item = options.find((option) => option.id === id);
114
- const ref = item ? Ref.fromDXN(DXN.parse(item.id)) : undefined;
115
- onValueChange(type, ref);
116
- },
117
- [options, type, onValueChange],
118
- );
119
-
120
128
  const item = handleGetValue();
121
129
  const selectedIds = useMemo(() => (item ? [item.id] : []), [item]);
122
130
  const createSchema = useSchema(db, typename);
123
131
 
132
+ // Lift the popover open state so we can dismiss it after a successful create.
133
+ const [open, setOpen] = useState(false);
134
+
124
135
  const handleCreate = useCallback(
125
- (values: any) => {
126
- if (createSchema && onCreate) {
127
- onCreate(createSchema, values);
136
+ async (values: any) => {
137
+ if (!createSchema || !onCreate) {
138
+ return;
139
+ }
140
+ const newObject = await onCreate(createSchema, values);
141
+ if (newObject) {
142
+ // Wire the newly-created object into this slot's form value. ArrayField
143
+ // owns the array; this RefField represents a single slot, so writing a
144
+ // Ref via `onValueChange` populates that slot.
145
+ onValueChange(type, Ref.make(newObject));
128
146
  }
147
+ setOpen(false);
129
148
  },
130
- [createSchema, onCreate],
149
+ [createSchema, onCreate, type, onValueChange],
150
+ );
151
+
152
+ const handleUpdate = useCallback(
153
+ (id: string | undefined) => {
154
+ const item = options.find((option) => option.id === id);
155
+ const ref = item ? Ref.fromDXN(DXN.parse(item.id)) : undefined;
156
+ onValueChange(type, ref);
157
+ },
158
+ [options, type, onValueChange],
131
159
  );
132
160
 
133
161
  const handleSelect = useCallback(
@@ -151,14 +179,14 @@ export const RefField = (props: RefFieldProps) => {
151
179
  <div>
152
180
  {readonly ? (
153
181
  !item ? (
154
- <p className={mx('text-description', 'mb-2')}>{t('empty-readonly-ref-field.label')}</p>
182
+ <p className='text-description mb-2'>{t('empty-readonly-ref-field.label')}</p>
155
183
  ) : (
156
184
  <DxAnchor key={item.id} dxn={item.id} rootclassname='me-1'>
157
185
  {item.label}
158
186
  </DxAnchor>
159
187
  )
160
188
  ) : (
161
- <ObjectPicker.Root>
189
+ <ObjectPicker.Root open={open} onOpenChange={setOpen}>
162
190
  <ObjectPicker.Trigger asChild classNames='p-0'>
163
191
  {item ? (
164
192
  <div className='flex gap-form-gap w-full'>
@@ -168,8 +196,8 @@ export const RefField = (props: RefFieldProps) => {
168
196
  </div>
169
197
  ) : (
170
198
  <Button classNames='w-full text-start gap-form-gap'>
171
- <div role='none' className='grow overflow-hidden'>
172
- <span className='flex truncate text-description'>{placeholder ?? t('ref-field.placeholder')}</span>
199
+ <div className='grow overflow-hidden'>
200
+ <span className='truncate text-description'>{placeholder ?? t('ref-field.placeholder')}</span>
173
201
  </div>
174
202
  <Icon size={3} icon='ph--caret-down--bold' />
175
203
  </Button>
@@ -180,7 +208,10 @@ export const RefField = (props: RefFieldProps) => {
180
208
  classNames='dx-card-popover-width'
181
209
  options={options}
182
210
  selectedIds={selectedIds}
183
- createSchema={createSchema && omitId(createSchema)}
211
+ // Strip hidden (`FormInputAnnotation.set(false)`) fields so the
212
+ // form's validator doesn't reject required-but-hidden fields
213
+ // such as backing-object refs supplied by a `FactoryAnnotation`.
214
+ createSchema={createSchema && omitHiddenFormFields(omitId(createSchema))}
184
215
  createOptionLabel={createOptionLabel}
185
216
  createOptionIcon={createOptionIcon}
186
217
  createInitialValuePath={createInitialValuePath}
@@ -9,7 +9,8 @@ import React, { useState } from 'react';
9
9
  import { SelectOption } from '@dxos/echo/internal';
10
10
  import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
11
 
12
- import { translations } from '../../../translations';
12
+ import { translations } from '#translations';
13
+
13
14
  import { TestLayout } from '../../testing';
14
15
  import { Form } from '../Form';
15
16
  import { SelectOptionField } from './SelectOptionField';
@@ -36,10 +37,12 @@ const DefaultStory = () => {
36
37
  fieldMap={{ options: SelectOptionField }}
37
38
  onSave={(values) => setValues(values)}
38
39
  >
39
- <Form.Content>
40
- <Form.FieldSet />
41
- <Form.Actions />
42
- </Form.Content>
40
+ <Form.Viewport>
41
+ <Form.Content>
42
+ <Form.FieldSet />
43
+ <Form.Actions />
44
+ </Form.Content>
45
+ </Form.Viewport>
43
46
  </Form.Root>
44
47
  </TestLayout>
45
48
  );