@dxos/react-ui-form 0.8.4-main.d05539e30a → 0.8.4-main.d9fc60f731

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/dist/lib/browser/index.mjs +1335 -561
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/browser/translations.mjs +2 -1
  5. package/dist/lib/browser/translations.mjs.map +2 -2
  6. package/dist/lib/node-esm/index.mjs +1335 -561
  7. package/dist/lib/node-esm/index.mjs.map +4 -4
  8. package/dist/lib/node-esm/meta.json +1 -1
  9. package/dist/lib/node-esm/translations.mjs +2 -1
  10. package/dist/lib/node-esm/translations.mjs.map +2 -2
  11. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +2 -2
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  13. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +2 -1
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/Form.d.ts +35 -9
  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 -16
  18. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/FormField.d.ts +14 -4
  20. package/dist/types/src/components/Form/FormField.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/FormFieldComponent.d.ts +22 -10
  22. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/FormFieldSet.d.ts +8 -2
  24. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/FormTooltipsContext.d.ts +19 -0
  26. package/dist/types/src/components/Form/FormTooltipsContext.d.ts.map +1 -0
  27. package/dist/types/src/components/Form/Layout/FormLayout.d.ts +50 -0
  28. package/dist/types/src/components/Form/Layout/FormLayout.d.ts.map +1 -0
  29. package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts +119 -0
  30. package/dist/types/src/components/Form/Layout/FormLayout.stories.d.ts.map +1 -0
  31. package/dist/types/src/components/Form/Layout/FormLayout.stories.test.d.ts +2 -0
  32. package/dist/types/src/components/Form/Layout/FormLayout.stories.test.d.ts.map +1 -0
  33. package/dist/types/src/components/Form/Layout/FormLayout.test.d.ts +2 -0
  34. package/dist/types/src/components/Form/Layout/FormLayout.test.d.ts.map +1 -0
  35. package/dist/types/src/components/Form/Layout/annotation.d.ts +30 -0
  36. package/dist/types/src/components/Form/Layout/annotation.d.ts.map +1 -0
  37. package/dist/types/src/components/Form/Layout/index.d.ts +4 -0
  38. package/dist/types/src/components/Form/Layout/index.d.ts.map +1 -0
  39. package/dist/types/src/components/Form/Layout/parser.d.ts +31 -0
  40. package/dist/types/src/components/Form/Layout/parser.d.ts.map +1 -0
  41. package/dist/types/src/components/Form/Layout/parser.test.d.ts +2 -0
  42. package/dist/types/src/components/Form/Layout/parser.test.d.ts.map +1 -0
  43. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -1
  44. package/dist/types/src/components/Form/fields/DateField.d.ts +4 -0
  45. package/dist/types/src/components/Form/fields/DateField.d.ts.map +1 -0
  46. package/dist/types/src/components/Form/fields/GeoPointField.d.ts +1 -1
  47. package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -1
  48. package/dist/types/src/components/Form/fields/InlineRefField.d.ts +13 -0
  49. package/dist/types/src/components/Form/fields/InlineRefField.d.ts.map +1 -0
  50. package/dist/types/src/components/Form/fields/MarkdownField.d.ts +1 -1
  51. package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -1
  52. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts +2 -1
  53. package/dist/types/src/components/Form/fields/MarkdownField.stories.d.ts.map +1 -1
  54. package/dist/types/src/components/Form/fields/RefField.d.ts +1 -1
  55. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -1
  56. package/dist/types/src/components/Form/fields/SelectField.d.ts +4 -1
  57. package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -1
  58. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts +1 -1
  59. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -1
  60. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +2 -1
  61. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -1
  62. package/dist/types/src/components/Form/fields/TupleField.d.ts +1 -1
  63. package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -1
  64. package/dist/types/src/components/Form/fields/index.d.ts +2 -0
  65. package/dist/types/src/components/Form/fields/index.d.ts.map +1 -1
  66. package/dist/types/src/components/Form/index.d.ts +3 -0
  67. package/dist/types/src/components/Form/index.d.ts.map +1 -1
  68. package/dist/types/src/components/Form/meta-tags.d.ts +30 -0
  69. package/dist/types/src/components/Form/meta-tags.d.ts.map +1 -0
  70. package/dist/types/src/components/Form/meta-tags.test.d.ts +2 -0
  71. package/dist/types/src/components/Form/meta-tags.test.d.ts.map +1 -0
  72. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +3 -4
  73. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -1
  74. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +2 -2
  75. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  76. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +2 -1
  77. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  78. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -1
  79. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +2 -1
  80. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -1
  81. package/dist/types/src/components/ObjectTree/ObjectTree.d.ts +9 -0
  82. package/dist/types/src/components/ObjectTree/ObjectTree.d.ts.map +1 -0
  83. package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts +87 -0
  84. package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts.map +1 -0
  85. package/dist/types/src/components/ObjectTree/index.d.ts +2 -0
  86. package/dist/types/src/components/ObjectTree/index.d.ts.map +1 -0
  87. package/dist/types/src/components/Settings/Settings.d.ts.map +1 -1
  88. package/dist/types/src/components/Settings/Settings.stories.d.ts +2 -1
  89. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +1 -1
  90. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +7 -8
  91. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  92. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +4 -4
  93. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  94. package/dist/types/src/components/index.d.ts +1 -0
  95. package/dist/types/src/components/index.d.ts.map +1 -1
  96. package/dist/types/src/components/testing.d.ts +9 -0
  97. package/dist/types/src/components/testing.d.ts.map +1 -1
  98. package/dist/types/src/translations.d.ts +2 -1
  99. package/dist/types/src/translations.d.ts.map +1 -1
  100. package/dist/types/src/util/properties.d.ts +11 -0
  101. package/dist/types/src/util/properties.d.ts.map +1 -1
  102. package/dist/types/tsconfig.tsbuildinfo +1 -1
  103. package/package.json +37 -32
  104. package/src/components/FieldEditor/FieldEditor.stories.tsx +2 -2
  105. package/src/components/FieldEditor/FieldEditor.test.tsx +4 -1
  106. package/src/components/FieldEditor/FieldEditor.tsx +19 -14
  107. package/src/components/Form/Form.stories.tsx +21 -27
  108. package/src/components/Form/Form.tsx +106 -19
  109. package/src/components/Form/FormField.tsx +85 -28
  110. package/src/components/Form/FormFieldComponent.tsx +75 -20
  111. package/src/components/Form/FormFieldSet.tsx +32 -3
  112. package/src/components/Form/FormTooltipsContext.ts +25 -0
  113. package/src/components/Form/Layout/FormLayout.stories.test.tsx +51 -0
  114. package/src/components/Form/Layout/FormLayout.stories.tsx +422 -0
  115. package/src/components/Form/Layout/FormLayout.test.ts +59 -0
  116. package/src/components/Form/Layout/FormLayout.tsx +251 -0
  117. package/src/components/Form/Layout/annotation.ts +38 -0
  118. package/src/components/Form/Layout/index.ts +7 -0
  119. package/src/components/Form/Layout/parser.test.ts +120 -0
  120. package/src/components/Form/Layout/parser.ts +167 -0
  121. package/src/components/Form/fields/ArrayField.tsx +61 -38
  122. package/src/components/Form/fields/DateField.tsx +101 -0
  123. package/src/components/Form/fields/GeoPointField.tsx +4 -1
  124. package/src/components/Form/fields/InlineRefField.tsx +146 -0
  125. package/src/components/Form/fields/MarkdownField.tsx +3 -3
  126. package/src/components/Form/fields/RefField.tsx +29 -23
  127. package/src/components/Form/fields/SelectField.tsx +17 -5
  128. package/src/components/Form/fields/SelectOptionField.tsx +4 -3
  129. package/src/components/Form/fields/TextAreaField.tsx +1 -1
  130. package/src/components/Form/fields/TupleField.tsx +2 -1
  131. package/src/components/Form/fields/index.ts +2 -0
  132. package/src/components/Form/index.ts +3 -0
  133. package/src/components/Form/meta-tags.test.ts +47 -0
  134. package/src/components/Form/meta-tags.ts +33 -0
  135. package/src/components/ObjectForm/ObjectForm.tsx +21 -26
  136. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +2 -2
  137. package/src/components/ObjectPicker/ObjectPicker.tsx +2 -2
  138. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +14 -26
  139. package/src/components/ObjectProperties/ObjectProperties.test.tsx +20 -10
  140. package/src/components/ObjectProperties/ObjectProperties.tsx +26 -30
  141. package/src/components/ObjectTree/ObjectTree.stories.tsx +134 -0
  142. package/src/components/ObjectTree/ObjectTree.tsx +190 -0
  143. package/src/components/ObjectTree/index.ts +5 -0
  144. package/src/components/Settings/Settings.tsx +2 -1
  145. package/src/components/Settings/SettingsItem.tsx +1 -1
  146. package/src/components/ViewEditor/ViewEditor.stories.tsx +35 -40
  147. package/src/components/ViewEditor/ViewEditor.test.tsx +11 -8
  148. package/src/components/ViewEditor/ViewEditor.tsx +29 -32
  149. package/src/components/index.ts +1 -0
  150. package/src/components/testing.tsx +4 -4
  151. package/src/translations.ts +2 -1
  152. package/src/util/properties.test.ts +67 -9
  153. package/src/util/properties.ts +36 -4
  154. package/src/vite-env.d.ts +10 -0
@@ -6,14 +6,22 @@ import * as Option from 'effect/Option';
6
6
  import * as SchemaAST from 'effect/SchemaAST';
7
7
  import React, { useCallback } from 'react';
8
8
 
9
- import { findNode, getArrayElementType, getDiscriminatedType, isDiscriminatedUnion, isNestedType } from '@dxos/effect';
10
- import { IconButton, useTranslation } from '@dxos/react-ui';
9
+ import { Ref } from '@dxos/echo';
10
+ import {
11
+ createJsonPath,
12
+ findNode,
13
+ getArrayElementType,
14
+ getDiscriminatedType,
15
+ isDiscriminatedUnion,
16
+ isNestedType,
17
+ } from '@dxos/effect';
18
+ import { useTranslation } from '@dxos/react-ui';
11
19
 
12
20
  import { translationKey } from '#translations';
13
21
 
14
22
  import { getFormProperties } from '../../../util';
15
23
  import { useFormValues } from '../Form';
16
- import { FormField, type FormFieldProps } from '../FormField';
24
+ import { FormField, IconBlock, type FormFieldProps } from '../FormField';
17
25
  import { FormFieldLabel, type FormFieldStateProps } from '../FormFieldComponent';
18
26
 
19
27
  export type ArrayFieldProps = {
@@ -55,7 +63,13 @@ export const ArrayField = ({
55
63
  const handleAdd = useCallback(() => {
56
64
  const defaultValue =
57
65
  isNestedType(type) && elementType ? getDefaultObjectValue(elementType) : getDefaultValue(elementType);
58
- values && onValueChange(type, [...values, defaultValue]);
66
+ // `values` is `undefined` on first render for arrays whose parent path
67
+ // hasn't been materialised in the form values yet (e.g. `package.repos`
68
+ // when the form starts with no `package`). `useFormValues`'s default-
69
+ // value effect would eventually backfill it to `[]`, but until then the
70
+ // old `values && ...` guard silently swallowed the click. Treat a
71
+ // missing array as empty so Add always produces `[defaultValue]`.
72
+ onValueChange(type, [...(values ?? []), defaultValue]);
59
73
  }, [onValueChange, type, elementType, values]);
60
74
 
61
75
  const handleDelete = useCallback(
@@ -72,53 +86,62 @@ export const ArrayField = ({
72
86
  return null;
73
87
  }
74
88
 
89
+ const renderItemAsObject = elementType && isNestedType(elementType) && !Ref.isRefType(elementType);
90
+
75
91
  return (
76
92
  <>
77
93
  {(layout !== 'static' || (values && values.length > 0)) && (
78
- <FormFieldLabel readonly={readonly} label={label} asChild />
94
+ <div className='flex items-center gap-2'>
95
+ <div className='flex-1 min-w-0'>
96
+ <FormFieldLabel readonly={readonly} label={label} path={createJsonPath(path ?? [])} asChild />
97
+ </div>
98
+ {!readonly && layout !== 'static' && (
99
+ <IconBlock inline icon='ph--plus--regular' label={t('add-item.button')} onClick={handleAdd} />
100
+ )}
101
+ </div>
79
102
  )}
80
103
 
81
- <div className='flex flex-col gap-form-gap'>
104
+ <div className='flex flex-col'>
82
105
  {values?.map((_, index) => {
83
- return (
84
- <div key={index} className='grid grid-cols-[1fr_min-content] gap-form-gap last:mb-form-gap items-center'>
85
- <FormField
86
- autoFocus={index === values.length - 1}
87
- type={elementType}
88
- path={[...(path ?? []), index]}
89
- readonly={readonly || layout === 'static'}
90
- layout='inline'
91
- {...props}
92
- />
106
+ const isLast = index === values.length - 1;
107
+
108
+ // Object items: each row contains a recursively-rendered FormField
109
+ // (multiple sub-rows for the object's fields) wrapped in a border,
110
+ // with the delete button bottom-aligned next to it.
111
+ // Scalar items: a single-row inline FormField with a center-aligned
112
+ // delete button. Refs and primitive arrays use this layout.
113
+ const fieldField = (
114
+ <FormField
115
+ {...props}
116
+ autoFocus={isLast}
117
+ type={elementType}
118
+ // Suppress the per-item header for object items only — the recursive
119
+ // form already renders labels for each sub-field. Scalar items
120
+ // (refs, primitives) keep the parent name so inline-layout
121
+ // children (e.g. RefField) have a real label to use as a
122
+ // fallback placeholder.
123
+ {...(renderItemAsObject && { name: null })}
124
+ path={[...(path ?? []), index]}
125
+ readonly={readonly || layout === 'static'}
126
+ layout={renderItemAsObject ? (layout === 'static' ? 'static' : undefined) : 'inline'}
127
+ />
128
+ );
93
129
 
130
+ return (
131
+ <div key={index} className='grid grid-cols-[1fr_min-content] items-center mb-1 last:mb-form-gap'>
132
+ {fieldField}
94
133
  {!readonly && layout !== 'static' && (
95
- <div className='flex flex-col h-full justify-end'>
96
- {/* NOTE: Aligns with center of last field if multi-field object. */}
97
- <div className='flex items-center h-[2rem]'>
98
- <IconButton
99
- icon='ph--x--regular'
100
- iconOnly
101
- label={t('remove.button')}
102
- onClick={() => handleDelete(index)}
103
- classNames='self-center'
104
- />
105
- </div>
106
- </div>
134
+ <IconBlock
135
+ inline={!renderItemAsObject}
136
+ icon='ph--x--regular'
137
+ label={t('remove-item.button')}
138
+ onClick={() => handleDelete(index)}
139
+ />
107
140
  )}
108
141
  </div>
109
142
  );
110
143
  })}
111
144
  </div>
112
-
113
- {/* TODO(burdon): Get label from schema. */}
114
- {!readonly && layout !== 'static' && (
115
- <IconButton
116
- classNames='flex w-full'
117
- icon='ph--plus--regular'
118
- label={t('add-item.button')}
119
- onClick={handleAdd}
120
- />
121
- )}
122
145
  </>
123
146
  );
124
147
  };
@@ -0,0 +1,101 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { format as formatDate } from 'date-fns';
6
+ import React, { useCallback } from 'react';
7
+
8
+ import { Format } from '@dxos/echo';
9
+ import { Input } from '@dxos/react-ui';
10
+
11
+ import { type FormFieldComponentProps, FormFieldWrapper } from '../FormFieldComponent';
12
+
13
+ /**
14
+ * Stored value shapes:
15
+ * - `Format.DateTime` -> ISO 8601 (`2018-11-13T20:20:39.000Z`).
16
+ * - `Format.Date` -> `YYYY-MM-DD`.
17
+ * - `Format.Time` -> `HH:mm:ss`.
18
+ *
19
+ * Segmented input value shapes (react-aria-components-backed):
20
+ * - `Input.DateTime` -> `YYYY-MM-DDTHH:mm` in local time.
21
+ * - `Input.Date` -> `YYYY-MM-DD`.
22
+ * - `Input.Time` -> `HH:mm`.
23
+ */
24
+
25
+ /** ISO 8601 → `YYYY-MM-DDTHH:mm` in the user's local timezone. */
26
+ const isoToLocalDateTime = (value: string | undefined): string => {
27
+ if (!value) {
28
+ return '';
29
+ }
30
+ const date = new Date(value);
31
+ if (Number.isNaN(date.getTime())) {
32
+ return '';
33
+ }
34
+ return formatDate(date, "yyyy-MM-dd'T'HH:mm");
35
+ };
36
+
37
+ /** `YYYY-MM-DDTHH:mm` (local) → ISO 8601 with timezone. */
38
+ const localDateTimeToIso = (value: string): string | undefined => {
39
+ if (!value) {
40
+ return undefined;
41
+ }
42
+ // Treat the input as local time. `new Date('YYYY-MM-DDTHH:mm')` parses as local.
43
+ const date = new Date(value);
44
+ return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
45
+ };
46
+
47
+ export const DateField = ({
48
+ type,
49
+ format,
50
+ readonly,
51
+ placeholder: _placeholder,
52
+ onValueChange,
53
+ onBlur: _onBlur,
54
+ ...props
55
+ }: FormFieldComponentProps<string>) => {
56
+ const handleSimpleChange = useCallback((next: string) => onValueChange(type, next), [type, onValueChange]);
57
+
58
+ const handleDateTimeChange = useCallback(
59
+ (next: string) => {
60
+ const iso = localDateTimeToIso(next);
61
+ onValueChange(type, iso as string);
62
+ },
63
+ [type, onValueChange],
64
+ );
65
+
66
+ return (
67
+ <FormFieldWrapper<string> readonly={readonly} format={format} {...props}>
68
+ {({ value }) => {
69
+ switch (format) {
70
+ case Format.TypeFormat.Date:
71
+ return (
72
+ <div className='grid grid-cols-[minmax(0,1fr)_min-content] items-stretch gap-1'>
73
+ <Input.Date
74
+ classNames='min-w-0 overflow-hidden'
75
+ disabled={!!readonly}
76
+ value={value ?? ''}
77
+ onValueChange={handleSimpleChange}
78
+ />
79
+ <Input.TriggerIcon classNames='h-full' />
80
+ </div>
81
+ );
82
+ case Format.TypeFormat.Time:
83
+ return <Input.Time disabled={!!readonly} value={value ?? ''} onValueChange={handleSimpleChange} />;
84
+ case Format.TypeFormat.DateTime:
85
+ default:
86
+ return (
87
+ <div className='grid grid-cols-[minmax(0,1fr)_min-content] items-stretch gap-1'>
88
+ <Input.DateTime
89
+ classNames='min-w-0 overflow-hidden'
90
+ disabled={!!readonly}
91
+ value={isoToLocalDateTime(value)}
92
+ onValueChange={handleDateTimeChange}
93
+ />
94
+ <Input.TriggerIcon classNames='h-full' />
95
+ </div>
96
+ );
97
+ }
98
+ }}
99
+ </FormFieldWrapper>
100
+ );
101
+ };
@@ -15,6 +15,7 @@ import { type FormFieldComponentProps, FormFieldLabel } from '../FormFieldCompon
15
15
  export const GeoPointField = ({
16
16
  type,
17
17
  label,
18
+ jsonPath,
18
19
  readonly,
19
20
  layout,
20
21
  getStatus,
@@ -59,7 +60,9 @@ export const GeoPointField = ({
59
60
 
60
61
  return (
61
62
  <Input.Root validationValence={status}>
62
- {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} asChild />}
63
+ {layout !== 'inline' && (
64
+ <FormFieldLabel error={error} readonly={readonly} label={label} path={jsonPath} asChild />
65
+ )}
63
66
  {layout === 'static' ? (
64
67
  <LatLng {...value} />
65
68
  ) : (
@@ -0,0 +1,146 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { useAtomValue } from '@effect-atom/atom-react';
6
+ import React, { useCallback, useMemo } from 'react';
7
+
8
+ import { type Database, Obj, Ref, Type } from '@dxos/echo';
9
+ import { AtomRef } from '@dxos/echo-atom';
10
+ import { useType as defaultUseType } from '@dxos/echo-react';
11
+ import {
12
+ type JsonPath,
13
+ ReferenceAnnotationId,
14
+ type ReferenceAnnotationValue,
15
+ splitJsonPath,
16
+ } from '@dxos/echo/internal';
17
+ import { findAnnotation } from '@dxos/effect';
18
+ import { Button, Icon, Input, useTranslation } from '@dxos/react-ui';
19
+
20
+ import { translationKey } from '#translations';
21
+
22
+ import { Form, omitId } from '../Form';
23
+ import { Nesting } from '../FormField';
24
+ import { FormFieldLabel } from '../FormFieldComponent';
25
+ import { type RefFieldProps } from './RefField';
26
+
27
+ /**
28
+ * Renders a referenced object's own fields inline (a nested form bound to the
29
+ * target) rather than the {@link RefField} picker. Activated by
30
+ * `FormInlineAnnotation.set(true)` on the `Ref` property.
31
+ *
32
+ * The nested form edits the target object directly: each changed path is
33
+ * written back via `Obj.setValue` within an `Obj.update`, mirroring
34
+ * `ObjectProperties` (without the synthetic meta-tags row).
35
+ */
36
+ export const InlineRefField = (props: RefFieldProps) => {
37
+ const {
38
+ type,
39
+ readonly,
40
+ label,
41
+ jsonPath,
42
+ layout,
43
+ db,
44
+ getValue,
45
+ onValueChange,
46
+ onCreate,
47
+ useType = defaultUseType,
48
+ } = props;
49
+ const { t } = useTranslation(translationKey);
50
+
51
+ const reference = getValue() as Ref.Ref<any> | undefined;
52
+ const typename = useMemo(
53
+ () => (type ? findAnnotation<ReferenceAnnotationValue>(type, ReferenceAnnotationId)?.typename : undefined),
54
+ [type],
55
+ );
56
+
57
+ // Empty ref: offer to create the referenced object so the inline form has a target.
58
+ const createType = useType(db, typename);
59
+ const handleCreate = useCallback(async () => {
60
+ if (!createType || !onCreate) {
61
+ return;
62
+ }
63
+ const newObject = await onCreate(createType, {});
64
+ if (newObject) {
65
+ onValueChange(type, Ref.make(newObject));
66
+ }
67
+ }, [createType, onCreate, type, onValueChange]);
68
+
69
+ if (readonly && !reference) {
70
+ return null;
71
+ }
72
+
73
+ return (
74
+ <Input.Root>
75
+ {layout !== 'inline' && <FormFieldLabel readonly={readonly} label={label} path={jsonPath} />}
76
+ {reference ? (
77
+ <InlineForm reference={reference} db={db} readonly={readonly} useType={useType} />
78
+ ) : (
79
+ !readonly &&
80
+ onCreate && (
81
+ <Button classNames='w-full gap-form-gap' disabled={!createType || !db} onClick={() => void handleCreate()}>
82
+ <Icon icon='ph--plus--regular' size={4} />
83
+ <span>{label || t('ref-field.placeholder')}</span>
84
+ </Button>
85
+ )
86
+ )}
87
+ </Input.Root>
88
+ );
89
+ };
90
+
91
+ type InlineFormProps = {
92
+ reference: Ref.Ref<any>;
93
+ db?: Database.Database;
94
+ readonly?: boolean;
95
+ // Accepts both the generic `defaultUseType` and `RefFieldProps['useType']`.
96
+ useType?: (db?: Database.Database, typename?: string) => Type.AnyEntity | undefined;
97
+ };
98
+
99
+ const InlineForm = ({ reference, db, readonly, useType = defaultUseType }: InlineFormProps) => {
100
+ const target = useAtomValue(useMemo(() => AtomRef.make(reference), [reference]));
101
+ const typename = target ? (Obj.getTypename(target) ?? undefined) : undefined;
102
+ const typeFromRegistry = useType(db, typename);
103
+ const targetType = (target && Obj.getType(target)) || typeFromRegistry;
104
+ // Drop the ECHO `id` property (mirrors `ObjectProperties` via `withMetaTags`);
105
+ // otherwise the nested form renders an "Id" field. Hidden fields
106
+ // (`FormInputAnnotation.set(false)`) are already filtered by the field set.
107
+ const formSchema = useMemo(() => (targetType ? omitId(Type.getSchema(targetType)) : undefined), [targetType]);
108
+ const defaultValues = useMemo(() => (target ? { ...target } : {}), [target]);
109
+
110
+ // `formSchema` is resolved at runtime (the referenced object's schema), so the
111
+ // form's value type is not statically known here — `any` at this form-value
112
+ // boundary mirrors `ObjectProperties`. Each changed path is written back to the
113
+ // live target via `Obj.setValue`.
114
+ const handleChange = useCallback(
115
+ (values: any, { isValid, changed }: { isValid: boolean; changed: Record<JsonPath, boolean> }) => {
116
+ if (!isValid || !target) {
117
+ return;
118
+ }
119
+ const changedPaths = (Object.keys(changed) as JsonPath[]).filter((path) => changed[path]);
120
+ if (changedPaths.length === 0) {
121
+ return;
122
+ }
123
+ Obj.update(target, () => {
124
+ for (const path of changedPaths) {
125
+ const parts = splitJsonPath(path);
126
+ Obj.setValue(target, parts, Obj.getValue(values, parts));
127
+ }
128
+ });
129
+ },
130
+ [target],
131
+ );
132
+
133
+ if (!target || !formSchema) {
134
+ return null;
135
+ }
136
+
137
+ return (
138
+ <Nesting>
139
+ <Form.Root schema={formSchema} defaultValues={defaultValues as any} db={db} onValuesChanged={handleChange}>
140
+ <Form.Content>
141
+ <Form.FieldSet readonly={readonly} />
142
+ </Form.Content>
143
+ </Form.Root>
144
+ </Nesting>
145
+ );
146
+ };
@@ -31,6 +31,7 @@ export const MarkdownField = ({
31
31
  type,
32
32
  readonly,
33
33
  label,
34
+ jsonPath,
34
35
  placeholder,
35
36
  layout,
36
37
  db,
@@ -80,7 +81,7 @@ export const MarkdownField = ({
80
81
 
81
82
  return (
82
83
  <Input.Root validationValence={status}>
83
- {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
84
+ {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} path={jsonPath} />}
84
85
  {layout === 'static' ? renderStatic() : renderEditor()}
85
86
  {layout === 'full' && <Input.Validation>{error}</Input.Validation>}
86
87
  </Input.Root>
@@ -104,8 +105,7 @@ type RefMarkdownEditorProps = {
104
105
  const RefMarkdownEditor = ({ reference, placeholder }: RefMarkdownEditorProps) => {
105
106
  const text = useAtomValue(useMemo(() => AtomRef.make(reference), [reference]));
106
107
  const dataExtensions = useMemo(
107
- () =>
108
- text ? [createDataExtensions({ id: reference.dxn.toString(), text: createDocAccessor(text, ['content']) })] : [],
108
+ () => (text ? [createDataExtensions({ id: reference.uri, text: createDocAccessor(text, ['content']) })] : []),
109
109
  [text, reference],
110
110
  );
111
111
  const extensions = useBasicMarkdownExtensions({ placeholder, extensions: dataExtensions });
@@ -6,10 +6,10 @@ 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 defaultUseSchema } from '@dxos/echo-react';
10
- import { ReferenceAnnotationId, type ReferenceAnnotationValue } from '@dxos/echo/internal';
9
+ import { useQuery, useType as defaultUseType } from '@dxos/echo-react';
10
+ import { ANY_OBJECT_TYPENAME, ReferenceAnnotationId, type ReferenceAnnotationValue } from '@dxos/echo/internal';
11
11
  import { findAnnotation } from '@dxos/effect';
12
- import { DXN } from '@dxos/keys';
12
+ import { URI } 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';
@@ -27,7 +27,7 @@ const isRefSnapshot = (val: any): val is { '/': string } => {
27
27
 
28
28
  const defaultGetOptions: NonNullable<RefFieldProps['getOptions']> = (results, { parentLabel } = {}) =>
29
29
  results.map((result) => {
30
- const id = Entity.getDXN(result).toString();
30
+ const id = Entity.getURI(result);
31
31
  const parent = parentLabel ? Obj.getParent(result as Obj.Unknown) : undefined;
32
32
  const label = parent ? Entity.getLabel(parent) : Entity.getLabel(result);
33
33
  return { id, label: label ?? id };
@@ -38,7 +38,7 @@ const defaultUseResults: NonNullable<RefFieldProps['useResults']> = (db, typenam
38
38
  db,
39
39
  typename
40
40
  ? // For Ref.Ref(Obj.Unknown) we want to show all objects.
41
- typename === Type.getTypename(Obj.Unknown)
41
+ typename === ANY_OBJECT_TYPENAME
42
42
  ? Filter.everything()
43
43
  : Filter.typename(typename)
44
44
  : Filter.nothing(),
@@ -51,7 +51,7 @@ export type RefFieldProps = FormFieldComponentProps &
51
51
  > & {
52
52
  db?: Database.Database;
53
53
  // TODO(burdon): Replace hooks with callbacks.
54
- useSchema?: (db?: Database.Database, typename?: string) => Type.AnyEntity;
54
+ useType?: (db?: Database.Database, typename?: string) => Type.AnyEntity;
55
55
  useResults?: (db?: Database.Database, typename?: string) => Entity.Any[];
56
56
  getOptions?: (objects: Entity.Any[], options?: { parentLabel?: boolean }) => RefOption[];
57
57
  /**
@@ -68,6 +68,7 @@ export const RefField = (props: RefFieldProps) => {
68
68
  type,
69
69
  readonly,
70
70
  label,
71
+ jsonPath,
71
72
  placeholder,
72
73
  layout,
73
74
  getStatus,
@@ -77,7 +78,7 @@ export const RefField = (props: RefFieldProps) => {
77
78
  createInitialValuePath,
78
79
  createFieldMap,
79
80
  db,
80
- useSchema = defaultUseSchema,
81
+ useType = defaultUseType,
81
82
  useResults = defaultUseResults,
82
83
  getOptions = defaultGetOptions,
83
84
  onCreate,
@@ -99,21 +100,24 @@ export const RefField = (props: RefFieldProps) => {
99
100
 
100
101
  const handleGetValue = useCallback(() => {
101
102
  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;
103
+ // Match form-value Refs against options by the bare entity id, not by full
104
+ // URI string. A just-created object's Ref (`Ref.make`) carries the LOCAL
105
+ // EID `echo:/<id>`, while options derived from `Entity.getURI` carry the
106
+ // qualified EID `echo://<spaceId>/<id>`; encoded snapshots use the DXN
107
+ // form `dxn:echo:<space|@>:<id>`. The entity id (a ULID) contains neither
108
+ // `:` nor `/`, so it is always the final delimiter-separated segment —
109
+ // splitting on both reconciles all three forms. Comparing full strings (or
110
+ // splitting on `:` alone, which leaves `/`-delimited EIDs intact) makes the
111
+ // just-created Ref's lookup fail and the slot render empty even though the
112
+ // underlying form value IS set.
113
+ const dxnToEntityId = (dxn: string): string => dxn.split(/[:/]/).filter(Boolean).pop() ?? dxn;
110
114
 
111
115
  const unknownToRefOption = (value: unknown) => {
112
116
  const isRef = Ref.isRef(value);
113
117
  if (isRef || isRefSnapshot(value)) {
114
- const dxnString = isRef ? value.dxn.toString() : value['/'];
115
- const objectId = dxnToObjectId(dxnString);
116
- const matchingOption = options.find((option) => dxnToObjectId(option.id) === objectId);
118
+ const dxnString = isRef ? value.uri : value['/'];
119
+ const objectId = dxnToEntityId(dxnString);
120
+ const matchingOption = options.find((option) => dxnToEntityId(option.id) === objectId);
117
121
  if (matchingOption) {
118
122
  return matchingOption;
119
123
  }
@@ -127,7 +131,7 @@ export const RefField = (props: RefFieldProps) => {
127
131
 
128
132
  const item = handleGetValue();
129
133
  const selectedIds = useMemo(() => (item ? [item.id] : []), [item]);
130
- const createSchema = useSchema(db, typename);
134
+ const createSchema = useType(db, typename);
131
135
 
132
136
  // Lift the popover open state so we can dismiss it after a successful create.
133
137
  const [open, setOpen] = useState(false);
@@ -152,7 +156,7 @@ export const RefField = (props: RefFieldProps) => {
152
156
  const handleUpdate = useCallback(
153
157
  (id: string | undefined) => {
154
158
  const item = options.find((option) => option.id === id);
155
- const ref = item ? Ref.fromDXN(DXN.parse(item.id)) : undefined;
159
+ const ref = item ? Ref.fromURI(URI.make(item.id)) : undefined;
156
160
  onValueChange(type, ref);
157
161
  },
158
162
  [options, type, onValueChange],
@@ -175,7 +179,7 @@ export const RefField = (props: RefFieldProps) => {
175
179
 
176
180
  return (
177
181
  <Input.Root validationValence={status}>
178
- {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
182
+ {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} path={jsonPath} />}
179
183
  <div>
180
184
  {readonly ? (
181
185
  !item ? (
@@ -197,7 +201,9 @@ export const RefField = (props: RefFieldProps) => {
197
201
  ) : (
198
202
  <Button classNames='w-full text-start gap-form-gap'>
199
203
  <div className='grow overflow-hidden'>
200
- <span className='truncate text-description'>{placeholder ?? t('ref-field.placeholder')}</span>
204
+ <span className='truncate text-description'>
205
+ {placeholder || label || t('ref-field.placeholder')}
206
+ </span>
201
207
  </div>
202
208
  <Icon size={3} icon='ph--caret-down--bold' />
203
209
  </Button>
@@ -211,7 +217,7 @@ export const RefField = (props: RefFieldProps) => {
211
217
  // Strip hidden (`FormInputAnnotation.set(false)`) fields so the
212
218
  // form's validator doesn't reject required-but-hidden fields
213
219
  // such as backing-object refs supplied by a `FactoryAnnotation`.
214
- createSchema={createSchema && omitHiddenFormFields(omitId(createSchema))}
220
+ createSchema={createSchema && omitHiddenFormFields(omitId(Type.getSchema(createSchema)))}
215
221
  createOptionLabel={createOptionLabel}
216
222
  createOptionIcon={createOptionIcon}
217
223
  createInitialValuePath={createInitialValuePath}
@@ -4,18 +4,20 @@
4
4
 
5
5
  import React, { useCallback } from 'react';
6
6
 
7
- import { Input, Select, type SelectRootProps } from '@dxos/react-ui';
7
+ import { Icon, Input, Select, type SelectRootProps } from '@dxos/react-ui';
8
+ import { getStyles } from '@dxos/ui-theme';
8
9
 
9
10
  import { type FormFieldComponentProps, FormFieldLabel } from '../FormFieldComponent';
10
11
 
11
12
  export type SelectFieldOptions = FormFieldComponentProps & {
12
- options?: Array<{ value: string | number; label?: string }>;
13
+ options?: Array<{ value: string | number; label?: string; secondaryLabel?: string; icon?: string; iconHue?: string }>;
13
14
  };
14
15
 
15
16
  export const SelectField = ({
16
17
  type,
17
18
  readonly,
18
19
  label,
20
+ jsonPath,
19
21
  layout,
20
22
  placeholder,
21
23
  options,
@@ -37,7 +39,7 @@ export const SelectField = ({
37
39
 
38
40
  return (
39
41
  <Input.Root validationValence={status}>
40
- {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
42
+ {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} path={jsonPath} />}
41
43
  {layout === 'static' ? (
42
44
  <p>{options?.find(({ value: optionValue }) => optionValue === value)?.label ?? String(value)}</p>
43
45
  ) : (
@@ -47,10 +49,14 @@ export const SelectField = ({
47
49
  <Select.Portal>
48
50
  <Select.Content>
49
51
  <Select.Viewport>
50
- {options?.map(({ value, label }) => (
52
+ {options?.map(({ value, label, secondaryLabel, icon, iconHue }) => (
51
53
  // NOTE: Numeric values are converted to and from strings.
52
54
  <Select.Option key={String(value)} value={String(value)}>
53
- {label ?? String(value)}
55
+ <span className='flex items-center flex-row gap-2'>
56
+ {icon && <Icon icon={icon} classNames={getIconHueStyles(iconHue)} />}
57
+ {label ?? String(value)}
58
+ {secondaryLabel && <span className='text-subdued text-xs'>{secondaryLabel}</span>}
59
+ </span>
54
60
  </Select.Option>
55
61
  ))}
56
62
  </Select.Viewport>
@@ -63,3 +69,9 @@ export const SelectField = ({
63
69
  </Input.Root>
64
70
  );
65
71
  };
72
+
73
+ const getIconHueStyles = (iconHue?: string): string | undefined => {
74
+ const styles = iconHue ? getStyles(iconHue) : undefined;
75
+
76
+ return styles?.fg;
77
+ };
@@ -19,6 +19,7 @@ export const SelectOptionField = ({
19
19
  type,
20
20
  readonly,
21
21
  label,
22
+ jsonPath,
22
23
  getStatus,
23
24
  getValue,
24
25
  onValueChange,
@@ -119,7 +120,7 @@ export const SelectOptionField = ({
119
120
 
120
121
  return (
121
122
  <Input.Root validationValence={status}>
122
- <FormFieldLabel error={error} readonly={readonly} label={label} />
123
+ <FormFieldLabel error={error} readonly={readonly} label={label} path={jsonPath} />
123
124
  <div>
124
125
  {options && (
125
126
  <List.Root
@@ -153,8 +154,8 @@ export const SelectOptionField = ({
153
154
  />
154
155
  </div>
155
156
  {selected === item.id && (
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>
157
+ <div className='flex flex-col p-form-padding gap-form-gap dx-density-md'>
158
+ <Input.Label classNames='text-sm'>{t('select-option.label')}</Input.Label>
158
159
  <div className='grid grid-cols-[1fr_min-content_min-content] gap-form-gap'>
159
160
  <Input.TextInput
160
161
  disabled={!!readonly}
@@ -25,7 +25,7 @@ export const TextAreaField = ({
25
25
  <FormFieldWrapper<string> readonly={readonly} {...props}>
26
26
  {({ value = '' }) => (
27
27
  <Input.TextArea
28
- rows={3}
28
+ rows={5}
29
29
  disabled={!!readonly}
30
30
  placeholder={placeholder}
31
31
  value={value}