@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
@@ -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,23 +2,22 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos/lit-ui/dx-tag-picker.pcss';
6
-
7
- import React, { useCallback, useMemo } from 'react';
5
+ import React, { useCallback, useMemo, useState } from 'react';
8
6
 
7
+ import '@dxos/lit-ui/dx-tag-picker.pcss';
9
8
  import { type Database, Entity, Filter, Obj, Ref, Type } from '@dxos/echo';
9
+ import { useQuery, useSchema as defaultUseSchema } from '@dxos/echo-react';
10
10
  import { ReferenceAnnotationId, type ReferenceAnnotationValue } from '@dxos/echo/internal';
11
- import { useQuery, useSchema as useSchema$ } from '@dxos/echo-react';
12
11
  import { findAnnotation } from '@dxos/effect';
13
12
  import { DXN } from '@dxos/keys';
14
13
  import { DxAnchor } from '@dxos/lit-ui/react';
15
14
  import { Button, Icon, Input, useTranslation } from '@dxos/react-ui';
16
15
  import { ParentLabelAnnotationId } from '@dxos/schema';
17
- import { mx } from '@dxos/ui-theme';
18
16
 
19
- import { translationKey } from '../../../translations';
17
+ import { translationKey } from '#translations';
18
+
20
19
  import { ObjectPicker, type ObjectPickerContentProps, type RefOption } from '../../ObjectPicker';
21
- import { omitId } from '../Form';
20
+ import { omitHiddenFormFields, omitId } from '../Form';
22
21
  import { type FormFieldComponentProps, FormFieldLabel } from '../FormFieldComponent';
23
22
 
24
23
  // TODO(burdon): Factor out.
@@ -34,7 +33,7 @@ const defaultGetOptions: NonNullable<RefFieldProps['getOptions']> = (results, {
34
33
  return { id, label: label ?? id };
35
34
  });
36
35
 
37
- const defaultResultsHook: NonNullable<RefFieldProps['resultsHook']> = (db, typename) =>
36
+ const defaultUseResults: NonNullable<RefFieldProps['useResults']> = (db, typename) =>
38
37
  useQuery(
39
38
  db,
40
39
  typename
@@ -46,12 +45,22 @@ const defaultResultsHook: NonNullable<RefFieldProps['resultsHook']> = (db, typen
46
45
  );
47
46
 
48
47
  export type RefFieldProps = FormFieldComponentProps &
49
- Pick<ObjectPickerContentProps, 'createOptionLabel' | 'createOptionIcon' | 'createInitialValuePath'> & {
48
+ Pick<
49
+ ObjectPickerContentProps,
50
+ 'createOptionLabel' | 'createOptionIcon' | 'createInitialValuePath' | 'createFieldMap'
51
+ > & {
50
52
  db?: Database.Database;
51
- resultsHook?: (db?: Database.Database, typename?: string) => Entity.Any[];
52
- 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[];
53
56
  getOptions?: (objects: Entity.Any[], options?: { parentLabel?: boolean }) => RefOption[];
54
- 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;
55
64
  };
56
65
 
57
66
  export const RefField = (props: RefFieldProps) => {
@@ -66,9 +75,10 @@ export const RefField = (props: RefFieldProps) => {
66
75
  createOptionLabel,
67
76
  createOptionIcon,
68
77
  createInitialValuePath,
78
+ createFieldMap,
69
79
  db,
70
- resultsHook: useResults = defaultResultsHook,
71
- schemaHook: useSchema = useSchema$,
80
+ useSchema = defaultUseSchema,
81
+ useResults = defaultUseResults,
72
82
  getOptions = defaultGetOptions,
73
83
  onCreate,
74
84
  onValueChange,
@@ -89,42 +99,63 @@ export const RefField = (props: RefFieldProps) => {
89
99
 
90
100
  const handleGetValue = useCallback(() => {
91
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;
92
110
 
93
111
  const unknownToRefOption = (value: unknown) => {
94
112
  const isRef = Ref.isRef(value);
95
113
  if (isRef || isRefSnapshot(value)) {
96
114
  const dxnString = isRef ? value.dxn.toString() : value['/'];
97
- const matchingOption = options.find((option) => option.id === dxnString);
115
+ const objectId = dxnToObjectId(dxnString);
116
+ const matchingOption = options.find((option) => dxnToObjectId(option.id) === objectId);
98
117
  if (matchingOption) {
99
118
  return matchingOption;
100
119
  }
101
120
  }
121
+
102
122
  return undefined;
103
123
  };
104
124
 
105
125
  return unknownToRefOption(formValue);
106
126
  }, [options, getValue]);
107
127
 
108
- const handleUpdate = useCallback(
109
- (id: string | undefined) => {
110
- const item = options.find((option) => option.id === id);
111
- const ref = item ? Ref.fromDXN(DXN.parse(item.id)) : undefined;
112
- onValueChange(type, ref);
113
- },
114
- [options, type, onValueChange],
115
- );
116
-
117
128
  const item = handleGetValue();
118
129
  const selectedIds = useMemo(() => (item ? [item.id] : []), [item]);
119
130
  const createSchema = useSchema(db, typename);
120
131
 
132
+ // Lift the popover open state so we can dismiss it after a successful create.
133
+ const [open, setOpen] = useState(false);
134
+
121
135
  const handleCreate = useCallback(
122
- (values: any) => {
123
- if (createSchema && onCreate) {
124
- 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));
125
146
  }
147
+ setOpen(false);
148
+ },
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);
126
157
  },
127
- [createSchema, onCreate],
158
+ [options, type, onValueChange],
128
159
  );
129
160
 
130
161
  const handleSelect = useCallback(
@@ -148,25 +179,25 @@ export const RefField = (props: RefFieldProps) => {
148
179
  <div>
149
180
  {readonly ? (
150
181
  !item ? (
151
- <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>
152
183
  ) : (
153
184
  <DxAnchor key={item.id} dxn={item.id} rootclassname='me-1'>
154
185
  {item.label}
155
186
  </DxAnchor>
156
187
  )
157
188
  ) : (
158
- <ObjectPicker.Root>
189
+ <ObjectPicker.Root open={open} onOpenChange={setOpen}>
159
190
  <ObjectPicker.Trigger asChild classNames='p-0'>
160
191
  {item ? (
161
- <div className='flex gap-2 w-full'>
192
+ <div className='flex gap-form-gap w-full'>
162
193
  <Input.Root key={item.id}>
163
194
  <Input.TextInput value={item.label} readOnly classNames='w-full' />
164
195
  </Input.Root>
165
196
  </div>
166
197
  ) : (
167
- <Button classNames='w-full text-start gap-2'>
168
- <div role='none' className='grow overflow-hidden'>
169
- <span className='flex truncate text-description'>{placeholder ?? t('ref field placeholder')}</span>
198
+ <Button classNames='w-full text-start gap-form-gap'>
199
+ <div className='grow overflow-hidden'>
200
+ <span className='truncate text-description'>{placeholder ?? t('ref-field.placeholder')}</span>
170
201
  </div>
171
202
  <Icon size={3} icon='ph--caret-down--bold' />
172
203
  </Button>
@@ -177,10 +208,14 @@ export const RefField = (props: RefFieldProps) => {
177
208
  classNames='dx-card-popover-width'
178
209
  options={options}
179
210
  selectedIds={selectedIds}
180
- 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))}
181
215
  createOptionLabel={createOptionLabel}
182
216
  createOptionIcon={createOptionIcon}
183
217
  createInitialValuePath={createInitialValuePath}
218
+ createFieldMap={createFieldMap}
184
219
  onCreate={handleCreate}
185
220
  onSelect={handleSelect}
186
221
  />
@@ -9,10 +9,10 @@ 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
16
  import { SelectOptionField } from './SelectOptionField';
17
17
 
18
18
  const schema = Schema.Struct({
@@ -37,10 +37,12 @@ const DefaultStory = () => {
37
37
  fieldMap={{ options: SelectOptionField }}
38
38
  onSave={(values) => setValues(values)}
39
39
  >
40
- <Form.Content>
41
- <Form.FieldSet />
42
- <Form.Actions />
43
- </Form.Content>
40
+ <Form.Viewport>
41
+ <Form.Content>
42
+ <Form.FieldSet />
43
+ <Form.Actions />
44
+ </Form.Content>
45
+ </Form.Viewport>
44
46
  </Form.Root>
45
47
  </TestLayout>
46
48
  );
@@ -9,9 +9,10 @@ import { PublicKey } from '@dxos/keys';
9
9
  import { type ChromaticPalette, IconButton, Input, Tag, useTranslation } from '@dxos/react-ui';
10
10
  import { List } from '@dxos/react-ui-list';
11
11
  import { HuePicker } from '@dxos/react-ui-pickers';
12
- import { hues, osTranslations, subtleHover } from '@dxos/ui-theme';
12
+ import { hues, osTranslations } from '@dxos/ui-theme';
13
+
14
+ import { translationKey } from '#translations';
13
15
 
14
- import { translationKey } from '../../../translations';
15
16
  import { type FormFieldComponentProps, FormFieldLabel } from '../FormFieldComponent';
16
17
 
17
18
  export const SelectOptionField = ({
@@ -119,7 +120,7 @@ export const SelectOptionField = ({
119
120
  return (
120
121
  <Input.Root validationValence={status}>
121
122
  <FormFieldLabel error={error} readonly={readonly} label={label} />
122
- <div role='none'>
123
+ <div>
123
124
  {options && (
124
125
  <List.Root
125
126
  items={options}
@@ -134,7 +135,7 @@ export const SelectOptionField = ({
134
135
  <List.Item
135
136
  role='button'
136
137
  item={item}
137
- classNames={[subtleHover, 'flex flex-col cursor-pointer rounded-xs']}
138
+ classNames='flex flex-col cursor-pointer rounded-xs dx-hover'
138
139
  aria-expanded={selected === item.id}
139
140
  >
140
141
  <div className='flex items-center'>
@@ -146,19 +147,18 @@ export const SelectOptionField = ({
146
147
  <IconButton
147
148
  iconOnly
148
149
  variant='ghost'
149
- label={t(selected === item.id ? 'collapse label' : 'expand label', { ns: osTranslations })}
150
+ label={t(selected === item.id ? 'collapse.label' : 'expand.label', { ns: osTranslations })}
150
151
  icon={selected === item.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
151
152
  onClick={() => handleClick(item.id)}
152
153
  />
153
154
  </div>
154
155
  {selected === item.id && (
155
- <div className='flex flex-col p-form-padding gap-1 dx-density-fine'>
156
- {/* 16px to match drag handle width. */}
157
- <Input.Label classNames='text-xs'>{t('select option label')}</Input.Label>
158
- <div className='grid grid-cols-[1fr_min-content_min-content] gap-1'>
156
+ <div className='flex flex-col p-form-padding gap-form-gap dx-density-fine'>
157
+ <Input.Label classNames='text-xs'>{t('select-option.label')}</Input.Label>
158
+ <div className='grid grid-cols-[1fr_min-content_min-content] gap-form-gap'>
159
159
  <Input.TextInput
160
160
  disabled={!!readonly}
161
- placeholder={t('select option label placeholder')}
161
+ placeholder={t('select-option-label.placeholder')}
162
162
  ref={selected === item.id ? inputRef : undefined}
163
163
  value={item.title}
164
164
  onChange={handleTitleChange(item.id)}
@@ -170,7 +170,7 @@ export const SelectOptionField = ({
170
170
  disabled={!!readonly}
171
171
  icon='ph--trash--fill'
172
172
  iconOnly
173
- label={t('select option delete')}
173
+ label={t('select-option-delete.button')}
174
174
  onClick={() => handleDelete(item.id)}
175
175
  />
176
176
  </div>
@@ -182,7 +182,7 @@ export const SelectOptionField = ({
182
182
  <IconButton
183
183
  variant='ghost'
184
184
  icon='ph--plus--regular'
185
- label={t('select option add')}
185
+ label={t('select-option-add.button')}
186
186
  onClick={handleAdd}
187
187
  disabled={!!readonly}
188
188
  classNames='w-full'
@@ -12,8 +12,8 @@ export const TextField = ({
12
12
  type,
13
13
  readonly,
14
14
  placeholder,
15
- onValueChange,
16
15
  onBlur,
16
+ onValueChange,
17
17
  ...props
18
18
  }: FormFieldComponentProps<string>) => {
19
19
  const handleChange = useCallback<NonNullable<TextInputProps['onChange']>>(
@@ -29,8 +29,8 @@ export const TextField = ({
29
29
  disabled={!!readonly}
30
30
  placeholder={placeholder}
31
31
  value={value}
32
- onChange={handleChange}
33
32
  onBlur={onBlur}
33
+ onChange={handleChange}
34
34
  />
35
35
  )}
36
36
  </FormFieldWrapper>
@@ -32,7 +32,7 @@ export const TupleField = ({
32
32
  return (
33
33
  <Input.Root validationValence={status}>
34
34
  <FormFieldLabel error={error} readonly={readonly} label={label} />
35
- <div className={mx('grid gap-2', gridCols[binding.length - 1])}>
35
+ <div className={mx('grid gap-form-gap', gridCols[binding.length - 1])}>
36
36
  {binding.map((prop) => (
37
37
  <Input.TextInput
38
38
  key={prop}
@@ -0,0 +1,114 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Schema from 'effect/Schema';
6
+ import React, { useCallback, useMemo } from 'react';
7
+
8
+ import { DXN, Obj, Ref, Tag, Type } from '@dxos/echo';
9
+ import { type JsonPath, splitJsonPath } from '@dxos/echo/internal';
10
+ import { invariant } from '@dxos/invariant';
11
+ import { HuePicker } from '@dxos/react-ui-pickers';
12
+ import { isNonNullable } from '@dxos/util';
13
+
14
+ import { translationKey } from '#translations';
15
+
16
+ import { Form, type FormFieldMap, omitId } from '../Form';
17
+
18
+ export type ObjectFormProps = {
19
+ schema: Schema.Schema.AnyNoContext;
20
+ object: Obj.Unknown;
21
+ };
22
+
23
+ const createFieldMap: FormFieldMap = {
24
+ hue: ({ type, label, layout, getValue, onValueChange }) => {
25
+ const handleChange = useCallback((nextHue: string) => onValueChange(type, nextHue), [onValueChange, type]);
26
+ const handleReset = useCallback(() => onValueChange(type, undefined), [onValueChange, type]);
27
+ return (
28
+ <>
29
+ {layout !== 'inline' && <Form.Label label={label} />}
30
+ <HuePicker value={getValue()} onChange={handleChange} onReset={handleReset} />
31
+ </>
32
+ );
33
+ },
34
+ };
35
+
36
+ export const ObjectForm = ({ object, schema }: ObjectFormProps) => {
37
+ const db = Obj.getDatabase(object);
38
+ const meta = Obj.getMeta(object);
39
+ const tags = (meta.tags ?? []).map((tag) => db?.makeRef(DXN.parse(tag))).filter(isNonNullable);
40
+ const values = useMemo(() => ({ tags, ...object }), [object, tags]);
41
+ const formSchema = useMemo(
42
+ () =>
43
+ omitId(
44
+ Schema.Struct({
45
+ tags: Schema.Array(Ref.Ref(Tag.Tag)).pipe(Schema.optional),
46
+ }).pipe(Schema.extend(schema)),
47
+ ),
48
+ [schema],
49
+ );
50
+
51
+ const handleCreate = useCallback((schema: Type.AnyEntity, values: any) => {
52
+ invariant(db);
53
+ invariant(Type.isObjectSchema(schema));
54
+ const newObject = db.add(Obj.make(schema, values));
55
+ if (Obj.instanceOf(Tag.Tag, newObject)) {
56
+ Obj.update(object, (object) => {
57
+ Obj.getMeta(object).tags = [...(Obj.getMeta(object).tags ?? []), Obj.getDXN(newObject).toString()];
58
+ });
59
+ }
60
+ }, []);
61
+
62
+ // TODO(wittjosiah): Use FormRootProps type.
63
+ const handleChange = useCallback(
64
+ ({ tags, ...values }: any, { isValid, changed }: { isValid: boolean; changed: Record<JsonPath, boolean> }) => {
65
+ if (!isValid) {
66
+ return;
67
+ }
68
+
69
+ const changedPaths = Object.keys(changed).filter((path) => changed[path as JsonPath]) as JsonPath[];
70
+
71
+ // Handle tags separately using Obj.update.
72
+ const hasTagsChange = changedPaths.some((path) => splitJsonPath(path)[0] === 'tags');
73
+ if (hasTagsChange) {
74
+ Obj.update(object, (object) => {
75
+ Obj.getMeta(object).tags = tags?.map((tag: Ref.Ref<Tag.Tag>) => tag.dxn.toString()) ?? [];
76
+ });
77
+ }
78
+
79
+ // Handle other property changes.
80
+ const nonTagPaths = changedPaths.filter((path) => splitJsonPath(path)[0] !== 'tags');
81
+ if (nonTagPaths.length > 0) {
82
+ Obj.update(object, () => {
83
+ for (const path of nonTagPaths) {
84
+ const parts = splitJsonPath(path);
85
+ const value = Obj.getValue(values, parts);
86
+ Obj.setValue(object, parts, value);
87
+ }
88
+ });
89
+ }
90
+ },
91
+ [object],
92
+ );
93
+
94
+ return (
95
+ <Form.Root
96
+ schema={formSchema}
97
+ defaultValues={values}
98
+ createTypename={Type.getTypename(Tag.Tag)}
99
+ createOptionIcon='ph--plus--regular'
100
+ createOptionLabel={['add-tag.label', { ns: translationKey }]}
101
+ createInitialValuePath='label'
102
+ createFieldMap={createFieldMap}
103
+ db={db}
104
+ onValuesChanged={handleChange}
105
+ onCreate={handleCreate}
106
+ >
107
+ <Form.Viewport>
108
+ <Form.Content>
109
+ <Form.FieldSet />
110
+ </Form.Content>
111
+ </Form.Viewport>
112
+ </Form.Root>
113
+ );
114
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './ObjectForm';