@dxos/react-ui-form 0.9.1-main.c7dcc2e112 → 0.9.1-staging.ee54ba693a

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 (211) hide show
  1. package/dist/lib/browser/index.mjs +1883 -1539
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node-esm/index.mjs +1883 -1539
  5. package/dist/lib/node-esm/index.mjs.map +4 -4
  6. package/dist/lib/node-esm/meta.json +1 -1
  7. package/dist/types/src/annotations.d.ts +47 -0
  8. package/dist/types/src/annotations.d.ts.map +1 -0
  9. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  10. package/dist/types/src/components/Form/Form.d.ts +12 -4
  11. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  12. package/dist/types/src/components/Form/Form.stories.d.ts +46 -65
  13. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  14. package/dist/types/src/components/Form/Form.theme.d.ts +252 -0
  15. package/dist/types/src/components/Form/Form.theme.d.ts.map +1 -0
  16. package/dist/types/src/components/Form/Form.theme.test.d.ts +2 -0
  17. package/dist/types/src/components/Form/Form.theme.test.d.ts.map +1 -0
  18. package/dist/types/src/components/Form/FormControls.d.ts +17 -8
  19. package/dist/types/src/components/Form/FormControls.d.ts.map +1 -1
  20. package/dist/types/src/components/Form/FormField/FormField.d.ts +16 -2
  21. package/dist/types/src/components/Form/FormField/FormField.d.ts.map +1 -1
  22. package/dist/types/src/components/Form/FormField/FormFieldHeader.d.ts +27 -0
  23. package/dist/types/src/components/Form/FormField/FormFieldHeader.d.ts.map +1 -0
  24. package/dist/types/src/components/Form/FormField/FormRow.d.ts +85 -0
  25. package/dist/types/src/components/Form/FormField/FormRow.d.ts.map +1 -0
  26. package/dist/types/src/components/Form/FormField/{FormFieldWrapper.stories.d.ts → FormRow.stories.d.ts} +4 -1
  27. package/dist/types/src/components/Form/FormField/FormRow.stories.d.ts.map +1 -0
  28. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.d.ts.map +1 -1
  29. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts +1 -0
  30. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts.map +1 -1
  31. package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/AsyncSelectField.d.ts +12 -0
  32. package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/AsyncSelectField.d.ts.map +1 -0
  33. package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/index.d.ts +2 -0
  34. package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/index.d.ts.map +1 -0
  35. package/dist/types/src/components/Form/FormField/fields/AutofillField/AutofillField.d.ts +12 -0
  36. package/dist/types/src/components/Form/FormField/fields/AutofillField/AutofillField.d.ts.map +1 -0
  37. package/dist/types/src/components/Form/FormField/fields/AutofillField/index.d.ts +2 -0
  38. package/dist/types/src/components/Form/FormField/fields/AutofillField/index.d.ts.map +1 -0
  39. package/dist/types/src/components/Form/FormField/fields/BooleanField/BooleanField.d.ts.map +1 -1
  40. package/dist/types/src/components/Form/FormField/fields/ComboboxField/ComboboxField.d.ts +12 -0
  41. package/dist/types/src/components/Form/FormField/fields/ComboboxField/ComboboxField.d.ts.map +1 -0
  42. package/dist/types/src/components/Form/FormField/fields/ComboboxField/index.d.ts +2 -0
  43. package/dist/types/src/components/Form/FormField/fields/ComboboxField/index.d.ts.map +1 -0
  44. package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.d.ts +1 -1
  45. package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.d.ts.map +1 -1
  46. package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.d.ts +1 -1
  47. package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.d.ts.map +1 -1
  48. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts +11 -0
  49. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts.map +1 -1
  50. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.test.d.ts +2 -0
  51. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.test.d.ts.map +1 -0
  52. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.d.ts +4 -0
  53. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.d.ts.map +1 -0
  54. package/dist/types/src/components/{Settings/Settings.stories.d.ts → Form/FormField/fields/PasswordField/PasswordField.stories.d.ts} +1 -2
  55. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.d.ts.map +1 -0
  56. package/dist/types/src/components/Form/FormField/fields/PasswordField/index.d.ts +2 -0
  57. package/dist/types/src/components/Form/FormField/fields/PasswordField/index.d.ts.map +1 -0
  58. package/dist/types/src/components/Form/FormField/fields/RefField/InlineRefField.d.ts.map +1 -1
  59. package/dist/types/src/components/Form/FormField/fields/RefField/RefField.d.ts.map +1 -1
  60. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts +18 -2
  61. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts.map +1 -1
  62. package/dist/types/src/components/Form/FormField/fields/index.d.ts +4 -0
  63. package/dist/types/src/components/Form/FormField/fields/index.d.ts.map +1 -1
  64. package/dist/types/src/components/Form/FormField/index.d.ts +4 -3
  65. package/dist/types/src/components/Form/FormField/index.d.ts.map +1 -1
  66. package/dist/types/src/components/Form/FormField/presentation.d.ts +22 -0
  67. package/dist/types/src/components/Form/FormField/presentation.d.ts.map +1 -0
  68. package/dist/types/src/components/Form/FormFieldSet/FormFieldSet.d.ts +8 -5
  69. package/dist/types/src/components/Form/FormFieldSet/FormFieldSet.d.ts.map +1 -1
  70. package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts +22 -0
  71. package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts.map +1 -0
  72. package/dist/types/src/components/Form/FormFieldSet/index.d.ts +1 -0
  73. package/dist/types/src/components/Form/FormFieldSet/index.d.ts.map +1 -1
  74. package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts +2 -0
  75. package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts.map +1 -1
  76. package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts +5 -5
  77. package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts.map +1 -1
  78. package/dist/types/src/components/Form/FormLayout/index.d.ts +1 -1
  79. package/dist/types/src/components/Form/FormLayout/index.d.ts.map +1 -1
  80. package/dist/types/src/components/Form/meta-tags.d.ts +1 -12
  81. package/dist/types/src/components/Form/meta-tags.d.ts.map +1 -1
  82. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  83. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +2 -1
  84. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -1
  85. package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts +2 -2
  86. package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts.map +1 -1
  87. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  88. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +2 -2
  89. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  90. package/dist/types/src/components/index.d.ts +0 -1
  91. package/dist/types/src/components/index.d.ts.map +1 -1
  92. package/dist/types/src/hooks/index.d.ts +1 -0
  93. package/dist/types/src/hooks/index.d.ts.map +1 -1
  94. package/dist/types/src/hooks/useAsyncFieldEffect.d.ts +24 -0
  95. package/dist/types/src/hooks/useAsyncFieldEffect.d.ts.map +1 -0
  96. package/dist/types/src/hooks/useFormContext.d.ts +8 -3
  97. package/dist/types/src/hooks/useFormContext.d.ts.map +1 -1
  98. package/dist/types/src/hooks/useFormHandler.d.ts +8 -2
  99. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  100. package/dist/types/src/index.d.ts +1 -0
  101. package/dist/types/src/index.d.ts.map +1 -1
  102. package/dist/types/src/testing/TestLayout.d.ts +1 -1
  103. package/dist/types/src/testing/TestLayout.d.ts.map +1 -1
  104. package/dist/types/src/testing/schema.d.ts +13 -16
  105. package/dist/types/src/testing/schema.d.ts.map +1 -1
  106. package/dist/types/src/types.d.ts +50 -1
  107. package/dist/types/src/types.d.ts.map +1 -1
  108. package/dist/types/src/util/index.d.ts +0 -1
  109. package/dist/types/src/util/index.d.ts.map +1 -1
  110. package/dist/types/src/util/omit.d.ts +1 -1
  111. package/dist/types/src/util/omit.d.ts.map +1 -1
  112. package/dist/types/tsconfig.tsbuildinfo +1 -1
  113. package/package.json +41 -40
  114. package/src/annotations.ts +63 -0
  115. package/src/components/FieldEditor/FieldEditor.tsx +5 -2
  116. package/src/components/Form/DESIGN.md +24 -0
  117. package/src/components/Form/Form.stories.tsx +257 -13
  118. package/src/components/Form/Form.theme.test.ts +33 -0
  119. package/src/components/Form/Form.theme.ts +86 -0
  120. package/src/components/Form/Form.tsx +15 -9
  121. package/src/components/Form/FormControls.tsx +94 -37
  122. package/src/components/Form/FormField/FormField.tsx +83 -32
  123. package/src/components/Form/FormField/FormFieldHeader.tsx +68 -0
  124. package/src/components/Form/FormField/{FormFieldWrapper.stories.tsx → FormRow.stories.tsx} +34 -9
  125. package/src/components/Form/FormField/FormRow.tsx +298 -0
  126. package/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.tsx +25 -9
  127. package/src/components/Form/FormField/fields/ArrayField/ArrayField.tsx +83 -24
  128. package/src/components/Form/FormField/fields/AsyncSelectField/AsyncSelectField.tsx +59 -0
  129. package/src/components/Form/FormField/fields/AsyncSelectField/index.ts +5 -0
  130. package/src/components/Form/FormField/fields/AutofillField/AutofillField.tsx +67 -0
  131. package/src/components/Form/FormField/fields/AutofillField/index.ts +5 -0
  132. package/src/components/Form/FormField/fields/BooleanField/BooleanField.tsx +5 -6
  133. package/src/components/Form/FormField/fields/ComboboxField/ComboboxField.tsx +104 -0
  134. package/src/components/Form/FormField/fields/ComboboxField/index.ts +5 -0
  135. package/src/components/Form/FormField/fields/DateField/DateField.tsx +9 -9
  136. package/src/components/Form/FormField/fields/GeoPointField/GeoPointField.tsx +17 -20
  137. package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.tsx +2 -2
  138. package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.tsx +39 -40
  139. package/src/components/Form/FormField/fields/NumberField/NumberField.test.ts +29 -0
  140. package/src/components/Form/FormField/fields/NumberField/NumberField.tsx +60 -21
  141. package/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.tsx +53 -0
  142. package/src/components/Form/FormField/fields/PasswordField/PasswordField.tsx +41 -0
  143. package/src/components/Form/FormField/fields/PasswordField/index.ts +5 -0
  144. package/src/components/Form/FormField/fields/RefField/InlineRefField.tsx +16 -10
  145. package/src/components/Form/FormField/fields/RefField/RefField.test.ts +8 -0
  146. package/src/components/Form/FormField/fields/RefField/RefField.tsx +50 -23
  147. package/src/components/Form/FormField/fields/SelectField/SelectField.tsx +104 -47
  148. package/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.tsx +3 -3
  149. package/src/components/Form/FormField/fields/TextAreaField/TextAreaField.tsx +3 -3
  150. package/src/components/Form/FormField/fields/TextField/TextField.tsx +3 -3
  151. package/src/components/Form/FormField/fields/TupleField/TupleField.tsx +1 -1
  152. package/src/components/Form/FormField/fields/index.ts +4 -0
  153. package/src/components/Form/FormField/index.ts +5 -3
  154. package/src/components/Form/FormField/presentation.tsx +32 -0
  155. package/src/components/Form/FormFieldSet/FormFieldSet.tsx +49 -70
  156. package/src/components/Form/FormFieldSet/FormFieldSetContainer.tsx +93 -0
  157. package/src/components/Form/FormFieldSet/index.ts +1 -0
  158. package/src/components/Form/FormLayout/FormLayout.stories.tsx +7 -5
  159. package/src/components/Form/FormLayout/FormLayout.tsx +24 -14
  160. package/src/components/Form/FormLayout/index.ts +1 -1
  161. package/src/components/Form/meta-tags.test.ts +1 -1
  162. package/src/components/ObjectForm/ObjectForm.tsx +2 -2
  163. package/src/components/ObjectPicker/ObjectPicker.tsx +1 -0
  164. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +1 -1
  165. package/src/components/ObjectProperties/ObjectProperties.tsx +12 -8
  166. package/src/components/RefEditor/RefEditor.stories.tsx +2 -2
  167. package/src/components/RefEditor/RefEditor.tsx +1 -1
  168. package/src/components/ViewEditor/ViewEditor.stories.tsx +2 -2
  169. package/src/components/ViewEditor/ViewEditor.test.tsx +8 -2
  170. package/src/components/ViewEditor/ViewEditor.tsx +3 -3
  171. package/src/components/index.ts +0 -1
  172. package/src/hooks/index.ts +1 -0
  173. package/src/hooks/useAsyncFieldEffect.ts +81 -0
  174. package/src/hooks/useFormContext.ts +6 -3
  175. package/src/hooks/useFormHandler.ts +11 -6
  176. package/src/index.ts +1 -0
  177. package/src/testing/TestLayout.tsx +15 -12
  178. package/src/testing/schema.ts +43 -43
  179. package/src/types.ts +53 -1
  180. package/src/util/index.ts +0 -1
  181. package/src/util/omit.ts +25 -11
  182. package/src/util/properties.test.ts +110 -28
  183. package/src/util/refs.ts +1 -1
  184. package/dist/types/src/components/Form/FormField/FieldHeader.d.ts +0 -19
  185. package/dist/types/src/components/Form/FormField/FieldHeader.d.ts.map +0 -1
  186. package/dist/types/src/components/Form/FormField/FormFieldWrapper.d.ts +0 -47
  187. package/dist/types/src/components/Form/FormField/FormFieldWrapper.d.ts.map +0 -1
  188. package/dist/types/src/components/Form/FormField/FormFieldWrapper.stories.d.ts.map +0 -1
  189. package/dist/types/src/components/Settings/Settings.d.ts +0 -51
  190. package/dist/types/src/components/Settings/Settings.d.ts.map +0 -1
  191. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +0 -1
  192. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +0 -43
  193. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +0 -1
  194. package/dist/types/src/components/Settings/SettingsItem.d.ts +0 -16
  195. package/dist/types/src/components/Settings/SettingsItem.d.ts.map +0 -1
  196. package/dist/types/src/components/Settings/SettingsSelectField.d.ts +0 -18
  197. package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +0 -1
  198. package/dist/types/src/components/Settings/index.d.ts +0 -4
  199. package/dist/types/src/components/Settings/index.d.ts.map +0 -1
  200. package/dist/types/src/util/field-type.d.ts +0 -19
  201. package/dist/types/src/util/field-type.d.ts.map +0 -1
  202. package/src/components/Form/FormField/FieldHeader.tsx +0 -36
  203. package/src/components/Form/FormField/FormFieldWrapper.tsx +0 -179
  204. package/src/components/Settings/AUDIT.md +0 -51
  205. package/src/components/Settings/Settings.stories.tsx +0 -104
  206. package/src/components/Settings/Settings.tsx +0 -81
  207. package/src/components/Settings/SettingsFieldSet.tsx +0 -257
  208. package/src/components/Settings/SettingsItem.tsx +0 -58
  209. package/src/components/Settings/SettingsSelectField.tsx +0 -59
  210. package/src/components/Settings/index.ts +0 -7
  211. package/src/util/field-type.ts +0 -60
@@ -0,0 +1,59 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { useEffect, useMemo, useRef } from 'react';
6
+
7
+ import { type AnyProperties } from '@dxos/echo/internal';
8
+
9
+ import { type FormFieldRendererProps } from '#types';
10
+
11
+ import { type OptionsLookup, type OptionsLookupEntry } from '../../../../../annotations';
12
+ import { pickValues, useAsyncFieldEffect, useFormValues } from '../../../../../hooks';
13
+ import { SelectField } from '../SelectField';
14
+
15
+ export type AsyncSelectFieldProps = FormFieldRendererProps<string> & {
16
+ /** Loads the selectable options from the lookup's declared dependency fields. */
17
+ lookup: OptionsLookup;
18
+ };
19
+
20
+ /**
21
+ * A select whose options are loaded dynamically (debounced) from the fields the lookup declares in its
22
+ * `deps`, via an {@link Annotation.OptionsLookup}. Re-runs only when one of those values changes.
23
+ * Auto-selects the sole option when exactly one resolves and nothing is chosen yet.
24
+ */
25
+ export const AsyncSelectField = ({ lookup, ...fieldProps }: AsyncSelectFieldProps) => {
26
+ const values = useFormValues<AnyProperties>(AsyncSelectField.displayName);
27
+ const subset = useMemo(() => pickValues(values, lookup.deps), [values, lookup.deps]);
28
+ const key = useMemo(() => JSON.stringify(subset), [subset]);
29
+ const { loading, data } = useAsyncFieldEffect<readonly OptionsLookupEntry[]>(() => lookup.load(subset), key);
30
+ const options = data ?? [];
31
+
32
+ const { type, placeholder } = fieldProps;
33
+ const current = fieldProps.getValue();
34
+ // `onValueChange` changes identity on every edit; hold it in a ref so auto-select depends only on the
35
+ // resolved `data` and current value (avoids an effect-refire loop).
36
+ const onValueChangeRef = useRef(fieldProps.onValueChange);
37
+ onValueChangeRef.current = fieldProps.onValueChange;
38
+ // Track which `data` reference has already been auto-selected so clearing the field does not trigger
39
+ // repeated re-selection of the same single option.
40
+ const autoSelectedDataRef = useRef<typeof data>(undefined);
41
+ useEffect(() => {
42
+ if ((current == null || current === '') && data?.length === 1 && data !== autoSelectedDataRef.current) {
43
+ autoSelectedDataRef.current = data;
44
+ onValueChangeRef.current(type, data[0].value);
45
+ }
46
+ }, [current, data, type]);
47
+
48
+ const resolvedPlaceholder = loading ? 'Loading…' : options.length === 0 ? 'No options available' : placeholder;
49
+
50
+ return (
51
+ <SelectField
52
+ {...fieldProps}
53
+ placeholder={resolvedPlaceholder}
54
+ options={options.map(({ value, label, secondaryLabel, icon }) => ({ value, label, secondaryLabel, icon }))}
55
+ />
56
+ );
57
+ };
58
+
59
+ AsyncSelectField.displayName = 'Form.AsyncSelectField';
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './AsyncSelectField';
@@ -0,0 +1,67 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { useEffect, useMemo, useRef } from 'react';
6
+
7
+ import { type AnyProperties } from '@dxos/echo/internal';
8
+
9
+ import { type FormFieldRendererProps } from '#types';
10
+
11
+ import { type Autofill } from '../../../../../annotations';
12
+ import { pickValues, useAsyncFieldEffect, useFormValues } from '../../../../../hooks';
13
+ import { TextField } from '../TextField';
14
+
15
+ export type AutofillFieldProps = FormFieldRendererProps<string> & {
16
+ /** Derives this field's value from the autofill's declared dependency fields (e.g. a sibling URL). */
17
+ autofill: Autofill;
18
+ };
19
+
20
+ /**
21
+ * An editable text field that pre-fills its value (debounced) from the fields the autofill declares in
22
+ * its `deps`, via an {@link Autofill}. Re-runs only when one of those values changes. The
23
+ * derived value is written only while the user has not typed their own (the field is empty or still
24
+ * holds the last auto-filled value), so a manual edit is never clobbered.
25
+ */
26
+ export const AutofillField = ({ autofill, ...fieldProps }: AutofillFieldProps) => {
27
+ const values = useFormValues<AnyProperties>(AutofillField.displayName);
28
+ const subset = useMemo(() => pickValues(values, autofill.deps), [values, autofill.deps]);
29
+ const key = useMemo(() => JSON.stringify(subset), [subset]);
30
+ const { data } = useAsyncFieldEffect<string | undefined>(() => autofill.derive(subset), key);
31
+
32
+ // Read field state through refs so the write effect depends only on the derived `data` — `getValue`
33
+ // and `onValueChange` change identity on every value edit, and depending on them would re-fire the
34
+ // effect after it writes, re-applying the same value in an infinite loop.
35
+ const { type } = fieldProps;
36
+ const getValueRef = useRef(fieldProps.getValue);
37
+ getValueRef.current = fieldProps.getValue;
38
+ const onValueChangeRef = useRef(fieldProps.onValueChange);
39
+ onValueChangeRef.current = fieldProps.onValueChange;
40
+ const lastFilledRef = useRef<string | undefined>(undefined);
41
+
42
+ useEffect(() => {
43
+ const current = getValueRef.current();
44
+ if (typeof data !== 'string' || data.length === 0) {
45
+ // Derivation stopped returning a value — clear the field if it still holds our last fill so stale
46
+ // auto-filled text doesn't persist after the source field is cleared.
47
+ if (lastFilledRef.current != null && current === lastFilledRef.current) {
48
+ lastFilledRef.current = undefined;
49
+ onValueChangeRef.current(type, '');
50
+ }
51
+ return;
52
+ }
53
+ if (current === data) {
54
+ lastFilledRef.current = data;
55
+ return;
56
+ }
57
+ // Only fill while the user has not typed their own value (empty, or still our last fill).
58
+ if (current == null || current === '' || current === lastFilledRef.current) {
59
+ lastFilledRef.current = data;
60
+ onValueChangeRef.current(type, data);
61
+ }
62
+ }, [data, type]);
63
+
64
+ return <TextField {...fieldProps} />;
65
+ };
66
+
67
+ AutofillField.displayName = 'Form.AutofillField';
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './AutofillField';
@@ -8,7 +8,7 @@ import { Input, type SwitchProps } from '@dxos/react-ui';
8
8
 
9
9
  import { type FormFieldRendererProps } from '#types';
10
10
 
11
- import { FormFieldWrapper } from '../../FormFieldWrapper';
11
+ import { FormRow } from '../../FormRow';
12
12
 
13
13
  export const BooleanField = ({ type, readonly, onValueChange, ...props }: FormFieldRendererProps<boolean>) => {
14
14
  const handleChange = useCallback<NonNullable<SwitchProps['onCheckedChange']>>(
@@ -17,13 +17,12 @@ export const BooleanField = ({ type, readonly, onValueChange, ...props }: FormFi
17
17
  );
18
18
 
19
19
  return (
20
- <FormFieldWrapper<boolean> readonly={readonly} {...props}>
20
+ <FormRow<boolean> readonly={readonly} {...props}>
21
21
  {({ value }) => (
22
- // TODO(burdon) Push down to react-ui components (e.g., Input.Root).
23
- <div className='flex items-center px-0.5 h-8'>
22
+ <Input.Block>
24
23
  <Input.Switch disabled={!!readonly} checked={value} onCheckedChange={handleChange} />
25
- </div>
24
+ </Input.Block>
26
25
  )}
27
- </FormFieldWrapper>
26
+ </FormRow>
28
27
  );
29
28
  };
@@ -0,0 +1,104 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { useCallback, useMemo, useState } from 'react';
6
+
7
+ import { type AnyProperties } from '@dxos/echo/internal';
8
+ import { Combobox } from '@dxos/react-ui-list';
9
+
10
+ import { type FormFieldRendererProps } from '#types';
11
+
12
+ import { type OptionsLookup, type OptionsLookupEntry } from '../../../../../annotations';
13
+ import { pickValues, useAsyncFieldEffect, useFormValues } from '../../../../../hooks';
14
+ import { FormRow } from '../../FormRow';
15
+
16
+ export type ComboboxFieldProps = FormFieldRendererProps<string> & {
17
+ /** Loads suggestions from the lookup's declared dependency fields (typically the field's own value). */
18
+ lookup: OptionsLookup;
19
+ };
20
+
21
+ /**
22
+ * An editable combobox built on the shared {@link Combobox} primitive (trigger + popover search list,
23
+ * the same family as `ObjectPicker`/`SearchList`). The trigger shows the current value; opening it reveals
24
+ * a search input that loads suggestions (debounced) via an {@link OptionsLookup} and filters
25
+ * them as you type. The literal typed text is offered as a fallback option at the bottom (deduped), so a
26
+ * value not among the suggestions can still be selected. No options are shown until something is typed.
27
+ */
28
+ export const ComboboxField = ({ lookup, type, readonly, placeholder, onValueChange, ...props }: ComboboxFieldProps) => {
29
+ const values = useFormValues<AnyProperties>(ComboboxField.displayName);
30
+ const ownKey = props.jsonPath;
31
+ const [query, setQuery] = useState('');
32
+
33
+ // When the combobox searches on its own field (its name is among `deps`), feed the live query to the
34
+ // loader so remote suggestions track typing; otherwise the loader reads the committed sibling values.
35
+ const lookupValues = useMemo(() => {
36
+ const subset = pickValues(values, lookup.deps);
37
+ if (ownKey && lookup.deps.includes(ownKey)) {
38
+ subset[ownKey] = query;
39
+ }
40
+ return subset;
41
+ }, [values, lookup.deps, ownKey, query]);
42
+ const key = useMemo(() => JSON.stringify(lookupValues), [lookupValues]);
43
+ const { data } = useAsyncFieldEffect<readonly OptionsLookupEntry[]>(() => lookup.load(lookupValues), key);
44
+
45
+ const trimmed = query.trim();
46
+ const normalized = trimmed.toLowerCase();
47
+ // No options until something is typed; then filter the loaded suggestions by the input text.
48
+ const results = useMemo(
49
+ () =>
50
+ normalized.length === 0
51
+ ? []
52
+ : (data ?? []).filter((option) => (option.label ?? option.value).toLowerCase().includes(normalized)),
53
+ [data, normalized],
54
+ );
55
+ const hasExact = results.some((option) => option.value === trimmed);
56
+
57
+ const handleValueChange = useCallback((next: string) => onValueChange(type, next), [onValueChange, type]);
58
+ const handleOpenChange = useCallback((open: boolean) => {
59
+ if (!open) {
60
+ setQuery('');
61
+ }
62
+ }, []);
63
+
64
+ return (
65
+ <FormRow<string>
66
+ readonly={readonly}
67
+ renderStatic={(value) => <p className='truncate min-w-0'>{value ?? ''}</p>}
68
+ {...props}
69
+ >
70
+ {({ value = '' }) => (
71
+ <Combobox.Root
72
+ value={value}
73
+ onValueChange={handleValueChange}
74
+ onOpenChange={handleOpenChange}
75
+ placeholder={placeholder}
76
+ >
77
+ {/* Full-width trigger (default renders value/placeholder + caret) to match the other fields. */}
78
+ <Combobox.Trigger disabled={!!readonly} classNames='w-full' />
79
+ <Combobox.Portal>
80
+ {/* Keep the first result highlighted as the list changes while typing. */}
81
+ <Combobox.Content resetSelectionOnChange>
82
+ <Combobox.Input autoFocus value={query} onValueChange={setQuery} placeholder={placeholder} />
83
+ <Combobox.List>
84
+ {results.map((option) => (
85
+ <Combobox.Item
86
+ key={option.value}
87
+ value={option.value}
88
+ label={option.label ?? option.value}
89
+ suffix={option.secondaryLabel}
90
+ />
91
+ ))}
92
+ {/* The literal typed text as a fallback option at the bottom, unless a suggestion already is it. */}
93
+ {normalized.length > 0 && !hasExact && <Combobox.Item value={trimmed} label={trimmed} />}
94
+ </Combobox.List>
95
+ <Combobox.Arrow />
96
+ </Combobox.Content>
97
+ </Combobox.Portal>
98
+ </Combobox.Root>
99
+ )}
100
+ </FormRow>
101
+ );
102
+ };
103
+
104
+ ComboboxField.displayName = 'Form.ComboboxField';
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './ComboboxField';
@@ -10,7 +10,7 @@ import { Input } from '@dxos/react-ui';
10
10
 
11
11
  import { type FormFieldRendererProps } from '#types';
12
12
 
13
- import { FormFieldWrapper } from '../../FormFieldWrapper';
13
+ import { FormRow } from '../../FormRow';
14
14
 
15
15
  /**
16
16
  * Stored value shapes:
@@ -66,19 +66,19 @@ export const DateField = ({
66
66
  );
67
67
 
68
68
  return (
69
- <FormFieldWrapper<string> readonly={readonly} format={format} {...props}>
69
+ <FormRow<string> {...props} readonly={readonly} format={format}>
70
70
  {({ value }) => {
71
71
  switch (format) {
72
72
  case Format.TypeFormat.Date:
73
73
  return (
74
- <div className='grid grid-cols-[minmax(0,1fr)_min-content] items-stretch gap-1'>
74
+ <div className='grid grid-cols-[minmax(0,1fr)_min-content] gap-1 items-stretch'>
75
75
  <Input.Date
76
76
  classNames='min-w-0 overflow-hidden'
77
- disabled={!!readonly}
77
+ disabled={readonly}
78
78
  value={value ?? ''}
79
79
  onValueChange={handleSimpleChange}
80
80
  />
81
- <Input.TriggerIcon classNames='h-full' />
81
+ <Input.TriggerIcon />
82
82
  </div>
83
83
  );
84
84
  case Format.TypeFormat.Time:
@@ -86,18 +86,18 @@ export const DateField = ({
86
86
  case Format.TypeFormat.DateTime:
87
87
  default:
88
88
  return (
89
- <div className='grid grid-cols-[minmax(0,1fr)_min-content] items-stretch gap-1'>
89
+ <div className='grid grid-cols-[minmax(0,1fr)_min-content] gap-1 items-stretch'>
90
90
  <Input.DateTime
91
91
  classNames='min-w-0 overflow-hidden'
92
- disabled={!!readonly}
92
+ disabled={readonly}
93
93
  value={isoToLocalDateTime(value)}
94
94
  onValueChange={handleDateTimeChange}
95
95
  />
96
- <Input.TriggerIcon classNames='h-full' />
96
+ <Input.TriggerIcon />
97
97
  </div>
98
98
  );
99
99
  }
100
100
  }}
101
- </FormFieldWrapper>
101
+ </FormRow>
102
102
  );
103
103
  };
@@ -11,21 +11,17 @@ import { safeParseFloat } from '@dxos/util';
11
11
  import { translationKey } from '#translations';
12
12
  import { type FormFieldRendererProps } from '#types';
13
13
 
14
- import { FormFieldLabel } from '../../FormFieldWrapper';
14
+ import { FormRow } from '../../FormRow';
15
15
 
16
16
  export const GeoPointField = ({
17
17
  type,
18
- label,
19
- jsonPath,
20
18
  readonly,
21
- layout,
22
- getStatus,
23
19
  getValue,
24
20
  onValueChange,
25
21
  onBlur,
22
+ ...props
26
23
  }: FormFieldRendererProps<GeoPoint>) => {
27
24
  const { t } = useTranslation(translationKey);
28
- const { status, error } = getStatus();
29
25
  const geoPoint = useMemo<GeoPoint>(() => getValue() ?? [0, 0], [getValue]);
30
26
  const value = useMemo(() => GeoLocation.fromGeoPoint(geoPoint), [geoPoint]);
31
27
 
@@ -55,22 +51,23 @@ export const GeoPointField = ({
55
51
  [type, getValue, onValueChange],
56
52
  );
57
53
 
58
- if ((readonly || layout === 'static') && !value?.latitude && !value?.longitude) {
59
- return null;
60
- }
61
-
62
54
  return (
63
- <Input.Root validationValence={status}>
64
- {layout !== 'inline' && (
65
- <FormFieldLabel error={error} readonly={readonly} label={label} path={jsonPath} standalone />
66
- )}
67
- {layout === 'static' ? (
68
- <LatLng {...value} />
69
- ) : (
55
+ <FormRow<GeoPoint>
56
+ readonly={readonly}
57
+ getValue={getValue}
58
+ standalone
59
+ renderStatic={(point) => {
60
+ const location = GeoLocation.fromGeoPoint(point ?? [0, 0]);
61
+ // Treat a zero coordinate pair as empty (no meaningful location to display).
62
+ return !location.latitude && !location.longitude ? null : <LatLng {...location} />;
63
+ }}
64
+ {...props}
65
+ >
66
+ {({ presentation }) => (
70
67
  <div className='grid grid-cols-2 gap-form-gap'>
71
68
  <div>
72
69
  <Input.Root>
73
- {layout !== 'inline' && <Input.Label>{t('latitude.label')}</Input.Label>}
70
+ {presentation.showLabel && <Input.Label>{t('latitude.label')}</Input.Label>}
74
71
  <Input.TextInput
75
72
  type='number'
76
73
  step='0.00001'
@@ -86,7 +83,7 @@ export const GeoPointField = ({
86
83
  </div>
87
84
  <div>
88
85
  <Input.Root>
89
- {layout !== 'inline' && <Input.Label>{t('longitude.label')}</Input.Label>}
86
+ {presentation.showLabel && <Input.Label>{t('longitude.label')}</Input.Label>}
90
87
  <Input.TextInput
91
88
  type='number'
92
89
  step='0.00001'
@@ -102,7 +99,7 @@ export const GeoPointField = ({
102
99
  </div>
103
100
  </div>
104
101
  )}
105
- </Input.Root>
102
+ </FormRow>
106
103
  );
107
104
  };
108
105
 
@@ -61,7 +61,7 @@ const RefStory = () => {
61
61
  return (
62
62
  <TestLayout>
63
63
  <Panel.Root>
64
- <Panel.Content asChild>
64
+ <Panel.Content>
65
65
  <Form.Root schema={TextNoteHolder} values={values as any} db={space.db}>
66
66
  <Form.Viewport>
67
67
  <Form.Content>
@@ -96,7 +96,7 @@ const EmptyRefStory = () => {
96
96
  return (
97
97
  <TestLayout>
98
98
  <Panel.Root>
99
- <Panel.Content asChild>
99
+ <Panel.Content>
100
100
  <Form.Root schema={EmptyRefSchema} defaultValues={{}} db={space.db}>
101
101
  <Form.Viewport>
102
102
  <Form.Content>
@@ -5,9 +5,9 @@
5
5
  import { useAtomValue } from '@effect-atom/atom-react';
6
6
  import React, { useCallback, useMemo } from 'react';
7
7
 
8
- import { type Database, Obj, Ref } from '@dxos/echo';
9
- import { createDocAccessor } from '@dxos/echo-client';
10
- import { Button, Icon, Input, useTranslation } from '@dxos/react-ui';
8
+ import { type Database, Ref } from '@dxos/echo';
9
+ import { Doc } from '@dxos/echo-doc';
10
+ import { IconButton, useTranslation } from '@dxos/react-ui';
11
11
  import { Editor, useBasicMarkdownExtensions } from '@dxos/react-ui-editor';
12
12
  import { Text } from '@dxos/schema';
13
13
  import { createDataExtensions } from '@dxos/ui-editor';
@@ -15,10 +15,17 @@ import { createDataExtensions } from '@dxos/ui-editor';
15
15
  import { translationKey } from '#translations';
16
16
  import { type FormFieldRendererProps } from '#types';
17
17
 
18
- import { FormFieldLabel } from '../../FormFieldWrapper';
18
+ import { FormRow } from '../../FormRow';
19
19
 
20
- const editorClassNames =
21
- 'transition-colors bg-input-surface focus-within:bg-focus-surface border border-separator rounded-xs p-1 px-2';
20
+ // Mirror the focus treatment of `Input` (default variant): a `focus-ring-subtle` ring of `dx-focus-line`
21
+ // width rather than a recolored 1px border, and suppress the editor wrapper's `accent-bg` border so a
22
+ // second click never turns the outline blue. Keyed on `focus-within` because focus lands on the inner
23
+ // CodeMirror contenteditable, not this wrapper.
24
+ const editorClassNames = [
25
+ 'min-h-[6lh] transition-colors rounded-xs p-1 px-2 bg-input-surface border border-input-separator',
26
+ 'focus:border-separator focus-within:border-separator focus-within:bg-focus-surface',
27
+ 'focus-within:ring-2 focus-within:ring-offset-0 focus-within:ring-focus-ring-subtle focus-within:z-[1]',
28
+ ];
22
29
 
23
30
  /**
24
31
  * Form field that edits a markdown value in a CodeMirror editor.
@@ -30,28 +37,30 @@ const editorClassNames =
30
37
  export const MarkdownField = ({
31
38
  type,
32
39
  readonly,
33
- label,
34
- jsonPath,
35
40
  placeholder,
36
- layout,
37
41
  db,
38
- getStatus,
39
42
  getValue,
40
43
  onValueChange,
44
+ ...props
41
45
  }: FormFieldRendererProps) => {
42
- const { status, error } = getStatus();
43
46
  const isRef = Ref.isRefType(type);
44
- const value = getValue();
45
47
 
46
- if ((readonly || layout === 'static') && value == null) {
47
- return null;
48
- }
48
+ // The field value is a runtime union the form's value type can't express: a `Ref<Text>` when the
49
+ // property is a markdown ref, or a plain string when it is annotated markdown text. `isRef`
50
+ // selects the branch, so the casts below narrow the opaque value accordingly.
51
+ const renderStatic = (value: unknown) => {
52
+ if (isRef) {
53
+ const reference = value as Ref.Ref<any> | undefined;
54
+ return reference ? <RefStaticText reference={reference} /> : null;
55
+ }
56
+ return <p className='whitespace-pre-wrap'>{(value as string | undefined) ?? ''}</p>;
57
+ };
49
58
 
50
- const renderEditor = () => {
59
+ const renderEditor = (value: unknown) => {
51
60
  if (isRef) {
52
61
  const reference = value as Ref.Ref<any> | undefined;
53
62
  if (reference) {
54
- return <RefMarkdownEditor reference={reference} placeholder={placeholder} />;
63
+ return <RefMarkdownEditor reference={reference} placeholder={placeholder} readonly={!!readonly} />;
55
64
  }
56
65
 
57
66
  if (readonly) {
@@ -71,20 +80,10 @@ export const MarkdownField = ({
71
80
  );
72
81
  };
73
82
 
74
- const renderStatic = () => {
75
- if (isRef) {
76
- const reference = value as Ref.Ref<any> | undefined;
77
- return reference ? <RefStaticText reference={reference} /> : null;
78
- }
79
- return <p className='whitespace-pre-wrap'>{(value as string | undefined) ?? ''}</p>;
80
- };
81
-
82
83
  return (
83
- <Input.Root validationValence={status}>
84
- {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} path={jsonPath} />}
85
- {layout === 'static' ? renderStatic() : renderEditor()}
86
- {layout === 'full' && <Input.Validation>{error}</Input.Validation>}
87
- </Input.Root>
84
+ <FormRow readonly={readonly} getValue={getValue} renderStatic={renderStatic} {...props}>
85
+ {({ value }) => renderEditor(value)}
86
+ </FormRow>
88
87
  );
89
88
  };
90
89
 
@@ -94,21 +93,23 @@ const RefStaticText = ({ reference }: { reference: Ref.Ref<any> }) => {
94
93
  if (!text?.content) {
95
94
  return null;
96
95
  }
96
+
97
97
  return <p className='whitespace-pre-wrap'>{text.content}</p>;
98
98
  };
99
99
 
100
100
  type RefMarkdownEditorProps = {
101
101
  reference: Ref.Ref<any>;
102
102
  placeholder?: string;
103
+ readonly?: boolean;
103
104
  };
104
105
 
105
- const RefMarkdownEditor = ({ reference, placeholder }: RefMarkdownEditorProps) => {
106
+ const RefMarkdownEditor = ({ reference, placeholder, readonly }: RefMarkdownEditorProps) => {
106
107
  const text = useAtomValue(useMemo(() => reference.atom, [reference]));
107
108
  const dataExtensions = useMemo(
108
- () => (text ? [createDataExtensions({ id: reference.uri, text: createDocAccessor(text, ['content']) })] : []),
109
+ () => (text ? [createDataExtensions({ id: reference.uri, text: Doc.createAccessor(text, ['content']) })] : []),
109
110
  [text, reference],
110
111
  );
111
- const extensions = useBasicMarkdownExtensions({ placeholder, extensions: dataExtensions });
112
+ const extensions = useBasicMarkdownExtensions({ placeholder, readonly, extensions: dataExtensions });
112
113
  if (!text) {
113
114
  return null;
114
115
  }
@@ -128,7 +129,7 @@ type StringMarkdownEditorProps = {
128
129
  };
129
130
 
130
131
  const StringMarkdownEditor = ({ value, placeholder, readonly, onChange }: StringMarkdownEditorProps) => {
131
- const extensions = useBasicMarkdownExtensions({ placeholder });
132
+ const extensions = useBasicMarkdownExtensions({ placeholder, readonly });
132
133
  const handleChange = useCallback((next: string) => onChange(next), [onChange]);
133
134
 
134
135
  return (
@@ -151,18 +152,16 @@ type CreateTextButtonProps = {
151
152
 
152
153
  const CreateTextButton = ({ db, disabled, onCreate }: CreateTextButtonProps) => {
153
154
  const { t } = useTranslation(translationKey);
155
+
154
156
  const handleClick = useCallback(() => {
155
157
  if (!db) {
156
158
  return;
157
159
  }
158
- const text = db.add(Obj.make(Text.Text, { content: '' }));
159
- onCreate(Ref.make(text));
160
+
161
+ onCreate(Ref.make(db.add(Text.make())));
160
162
  }, [db, onCreate]);
161
163
 
162
164
  return (
163
- <Button onClick={handleClick} disabled={disabled}>
164
- <Icon icon='ph--plus--regular' size={4} />
165
- <span>{t('create-text.label')}</span>
166
- </Button>
165
+ <IconButton icon='ph--plus--regular' label={t('create-text.label')} onClick={handleClick} disabled={disabled} />
167
166
  );
168
167
  };
@@ -0,0 +1,29 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Schema from 'effect/Schema';
6
+ import { describe, test } from 'vitest';
7
+
8
+ import { getNumericConstraints } from './NumberField';
9
+
10
+ describe('getNumericConstraints', () => {
11
+ test('reads min/max from Schema.between (bounds live on separate nested refinements)', ({ expect }) => {
12
+ const { ast } = Schema.Number.pipe(Schema.between(0, 23));
13
+ expect(getNumericConstraints(ast)).toEqual({ min: 0, max: 23, integer: false });
14
+ });
15
+
16
+ test('detects integer from Schema.int', ({ expect }) => {
17
+ const { ast } = Schema.Number.pipe(Schema.int(), Schema.between(0, 23));
18
+ expect(getNumericConstraints(ast)).toEqual({ min: 0, max: 23, integer: true });
19
+ });
20
+
21
+ test('returns empty constraints for an unconstrained number', ({ expect }) => {
22
+ expect(getNumericConstraints(Schema.Number.ast)).toEqual({ integer: false });
23
+ });
24
+
25
+ test('aggregates stacked bounds to the strictest (intersection semantics)', ({ expect }) => {
26
+ const { ast } = Schema.Number.pipe(Schema.greaterThanOrEqualTo(5), Schema.greaterThanOrEqualTo(10));
27
+ expect(getNumericConstraints(ast)).toEqual({ min: 10, max: undefined, integer: false });
28
+ });
29
+ });