@dxos/react-ui-form 0.10.0 → 0.11.0

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 (182) hide show
  1. package/dist/lib/index.mjs +4064 -0
  2. package/dist/lib/index.mjs.map +1 -0
  3. package/dist/lib/translations.mjs +77 -0
  4. package/dist/lib/translations.mjs.map +1 -0
  5. package/dist/lib/types.mjs +0 -0
  6. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +1 -0
  7. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  8. package/dist/types/src/components/Form/Card.stories.d.ts +88 -0
  9. package/dist/types/src/components/Form/Card.stories.d.ts.map +1 -0
  10. package/dist/types/src/components/Form/Form.d.ts +7 -7
  11. package/dist/types/src/components/Form/Form.stories.d.ts +21 -0
  12. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  13. package/dist/types/src/components/Form/Form.theme.d.ts +6 -0
  14. package/dist/types/src/components/Form/Form.theme.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/FormControls.d.ts +7 -7
  16. package/dist/types/src/components/Form/FormControls.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/FormField/FormField.d.ts +1 -1
  18. package/dist/types/src/components/Form/FormField/FormField.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/FormField/FormRow.d.ts +17 -3
  20. package/dist/types/src/components/Form/FormField/FormRow.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.d.ts +0 -6
  22. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts +1 -0
  24. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/FormField/fields/ArrayField/default-value.d.ts +7 -0
  26. package/dist/types/src/components/Form/FormField/fields/ArrayField/default-value.d.ts.map +1 -0
  27. package/dist/types/src/components/Form/FormField/fields/ArrayField/index.d.ts +1 -0
  28. package/dist/types/src/components/Form/FormField/fields/ArrayField/index.d.ts.map +1 -1
  29. package/dist/types/src/components/Form/FormField/fields/BooleanField/BooleanField.stories.d.ts +1 -0
  30. package/dist/types/src/components/Form/FormField/fields/BooleanField/BooleanField.stories.d.ts.map +1 -1
  31. package/dist/types/src/components/Form/FormField/fields/DateField/DateField.d.ts.map +1 -1
  32. package/dist/types/src/components/Form/FormField/fields/DateField/DateField.stories.d.ts +1 -0
  33. package/dist/types/src/components/Form/FormField/fields/DateField/DateField.stories.d.ts.map +1 -1
  34. package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.stories.d.ts +1 -0
  35. package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.stories.d.ts.map +1 -1
  36. package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.d.ts.map +1 -1
  37. package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.d.ts +1 -0
  38. package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.d.ts.map +1 -1
  39. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts +0 -11
  40. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts.map +1 -1
  41. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.stories.d.ts +1 -0
  42. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.stories.d.ts.map +1 -1
  43. package/dist/types/src/components/Form/FormField/fields/NumberField/index.d.ts +1 -0
  44. package/dist/types/src/components/Form/FormField/fields/NumberField/index.d.ts.map +1 -1
  45. package/dist/types/src/components/Form/FormField/fields/NumberField/numeric-constraints.d.ts +12 -0
  46. package/dist/types/src/components/Form/FormField/fields/NumberField/numeric-constraints.d.ts.map +1 -0
  47. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.d.ts.map +1 -1
  48. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.d.ts +1 -0
  49. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.d.ts.map +1 -1
  50. package/dist/types/src/components/Form/FormField/fields/RefField/RefField.d.ts +1 -11
  51. package/dist/types/src/components/Form/FormField/fields/RefField/RefField.d.ts.map +1 -1
  52. package/dist/types/src/components/Form/FormField/fields/RefField/RefField.stories.d.ts +1 -0
  53. package/dist/types/src/components/Form/FormField/fields/RefField/RefField.stories.d.ts.map +1 -1
  54. package/dist/types/src/components/Form/FormField/fields/RefField/find-ref-option.d.ts +13 -0
  55. package/dist/types/src/components/Form/FormField/fields/RefField/find-ref-option.d.ts.map +1 -0
  56. package/dist/types/src/components/Form/FormField/fields/RefField/index.d.ts +1 -0
  57. package/dist/types/src/components/Form/FormField/fields/RefField/index.d.ts.map +1 -1
  58. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts +1 -13
  59. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts.map +1 -1
  60. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.stories.d.ts +1 -0
  61. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.stories.d.ts.map +1 -1
  62. package/dist/types/src/components/Form/FormField/fields/SelectField/create-select-field.d.ts +15 -0
  63. package/dist/types/src/components/Form/FormField/fields/SelectField/create-select-field.d.ts.map +1 -0
  64. package/dist/types/src/components/Form/FormField/fields/SelectField/index.d.ts +1 -0
  65. package/dist/types/src/components/Form/FormField/fields/SelectField/index.d.ts.map +1 -1
  66. package/dist/types/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.d.ts.map +1 -1
  67. package/dist/types/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.stories.d.ts +1 -0
  68. package/dist/types/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.stories.d.ts.map +1 -1
  69. package/dist/types/src/components/Form/FormField/fields/TextAreaField/TextAreaField.stories.d.ts +1 -0
  70. package/dist/types/src/components/Form/FormField/fields/TextAreaField/TextAreaField.stories.d.ts.map +1 -1
  71. package/dist/types/src/components/Form/FormField/fields/TextField/TextField.stories.d.ts +1 -0
  72. package/dist/types/src/components/Form/FormField/fields/TextField/TextField.stories.d.ts.map +1 -1
  73. package/dist/types/src/components/Form/FormField/fields/TupleField/TupleField.stories.d.ts +1 -0
  74. package/dist/types/src/components/Form/FormField/fields/TupleField/TupleField.stories.d.ts.map +1 -1
  75. package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts +1 -1
  76. package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts.map +1 -1
  77. package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts +0 -27
  78. package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts.map +1 -1
  79. package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts +1 -0
  80. package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts.map +1 -1
  81. package/dist/types/src/components/Form/FormLayout/index.d.ts +1 -0
  82. package/dist/types/src/components/Form/FormLayout/index.d.ts.map +1 -1
  83. package/dist/types/src/components/Form/FormLayout/resolve-layout-field.d.ts +29 -0
  84. package/dist/types/src/components/Form/FormLayout/resolve-layout-field.d.ts.map +1 -0
  85. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts +10 -1
  86. package/dist/types/src/components/ObjectForm/ObjectForm.d.ts.map +1 -1
  87. package/dist/types/src/components/ObjectForm/ObjectForm.stories.d.ts +2 -0
  88. package/dist/types/src/components/ObjectForm/ObjectForm.stories.d.ts.map +1 -1
  89. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +8 -23
  90. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  91. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +1 -0
  92. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -1
  93. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +1 -1
  94. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -1
  95. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts +1 -0
  96. package/dist/types/src/components/ObjectProperties/ObjectProperties.stories.d.ts.map +1 -1
  97. package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts +1 -0
  98. package/dist/types/src/components/ObjectTree/ObjectTree.stories.d.ts.map +1 -1
  99. package/dist/types/src/components/RefEditor/RefEditor.d.ts.map +1 -1
  100. package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts +2 -0
  101. package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts.map +1 -1
  102. package/dist/types/src/components/RefEditor/ref-editor-extension.d.ts +9 -8
  103. package/dist/types/src/components/RefEditor/ref-editor-extension.d.ts.map +1 -1
  104. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +1 -0
  105. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  106. package/dist/types/src/hooks/useFormHandler.d.ts +6 -2
  107. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  108. package/dist/types/src/hooks/useFormHandler.test.d.ts +2 -0
  109. package/dist/types/src/hooks/useFormHandler.test.d.ts.map +1 -0
  110. package/dist/types/src/testing/TestLayout.d.ts +1 -1
  111. package/dist/types/src/testing/TestLayout.d.ts.map +1 -1
  112. package/dist/types/src/translations.d.ts +1 -0
  113. package/dist/types/src/translations.d.ts.map +1 -1
  114. package/dist/types/src/types.d.ts +5 -0
  115. package/dist/types/src/types.d.ts.map +1 -1
  116. package/dist/types/src/util/index.d.ts +0 -1
  117. package/dist/types/src/util/index.d.ts.map +1 -1
  118. package/dist/types/tsconfig.tsbuildinfo +1 -1
  119. package/package.json +39 -42
  120. package/src/components/Form/Card.stories.tsx +93 -0
  121. package/src/components/Form/Form.stories.tsx +95 -3
  122. package/src/components/Form/Form.theme.ts +14 -8
  123. package/src/components/Form/Form.tsx +2 -2
  124. package/src/components/Form/FormControls.tsx +32 -13
  125. package/src/components/Form/FormField/FormField.tsx +11 -3
  126. package/src/components/Form/FormField/FormRow.tsx +20 -1
  127. package/src/components/Form/FormField/fields/ArrayField/ArrayField.tsx +3 -46
  128. package/src/components/Form/FormField/fields/ArrayField/default-value.ts +49 -0
  129. package/src/components/Form/FormField/fields/ArrayField/index.ts +2 -0
  130. package/src/components/Form/FormField/fields/ComboboxField/ComboboxField.tsx +12 -2
  131. package/src/components/Form/FormField/fields/DateField/DateField.tsx +10 -3
  132. package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.tsx +1 -1
  133. package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.tsx +2 -12
  134. package/src/components/Form/FormField/fields/NumberField/NumberField.test.ts +1 -1
  135. package/src/components/Form/FormField/fields/NumberField/NumberField.tsx +1 -36
  136. package/src/components/Form/FormField/fields/NumberField/index.ts +2 -0
  137. package/src/components/Form/FormField/fields/NumberField/numeric-constraints.ts +43 -0
  138. package/src/components/Form/FormField/fields/PasswordField/PasswordField.tsx +1 -0
  139. package/src/components/Form/FormField/fields/RefField/InlineRefField.tsx +2 -2
  140. package/src/components/Form/FormField/fields/RefField/RefField.test.ts +1 -1
  141. package/src/components/Form/FormField/fields/RefField/RefField.tsx +3 -45
  142. package/src/components/Form/FormField/fields/RefField/find-ref-option.ts +56 -0
  143. package/src/components/Form/FormField/fields/RefField/index.ts +2 -0
  144. package/src/components/Form/FormField/fields/SelectField/SelectField.tsx +2 -67
  145. package/src/components/Form/FormField/fields/SelectField/create-select-field.tsx +80 -0
  146. package/src/components/Form/FormField/fields/SelectField/index.ts +2 -0
  147. package/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.tsx +2 -1
  148. package/src/components/Form/FormFieldSet/FormFieldSetContainer.tsx +11 -9
  149. package/src/components/Form/FormLayout/FormLayout.test.ts +1 -1
  150. package/src/components/Form/FormLayout/FormLayout.tsx +1 -86
  151. package/src/components/Form/FormLayout/index.ts +2 -0
  152. package/src/components/Form/FormLayout/resolve-layout-field.ts +98 -0
  153. package/src/components/ObjectForm/ObjectForm.stories.tsx +93 -3
  154. package/src/components/ObjectForm/ObjectForm.tsx +22 -4
  155. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +1 -1
  156. package/src/components/ObjectPicker/ObjectPicker.tsx +3 -3
  157. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +1 -1
  158. package/src/components/ObjectProperties/ObjectProperties.tsx +7 -1
  159. package/src/components/ObjectTree/ObjectTree.stories.tsx +1 -1
  160. package/src/components/RefEditor/RefEditor.stories.tsx +9 -1
  161. package/src/components/RefEditor/RefEditor.tsx +42 -6
  162. package/src/components/RefEditor/ref-editor-extension.ts +168 -91
  163. package/src/components/ViewEditor/ViewEditor.stories.tsx +1 -1
  164. package/src/components/ViewEditor/ViewEditor.tsx +9 -9
  165. package/src/hooks/useFormHandler.test.ts +85 -0
  166. package/src/hooks/useFormHandler.ts +118 -31
  167. package/src/translations.ts +1 -0
  168. package/src/types.ts +5 -0
  169. package/src/util/index.ts +0 -1
  170. package/dist/lib/browser/index.mjs +0 -4675
  171. package/dist/lib/browser/index.mjs.map +0 -7
  172. package/dist/lib/browser/meta.json +0 -1
  173. package/dist/lib/browser/translations.mjs +0 -87
  174. package/dist/lib/browser/translations.mjs.map +0 -7
  175. package/dist/lib/node-esm/index.mjs +0 -4677
  176. package/dist/lib/node-esm/index.mjs.map +0 -7
  177. package/dist/lib/node-esm/meta.json +0 -1
  178. package/dist/lib/node-esm/translations.mjs +0 -89
  179. package/dist/lib/node-esm/translations.mjs.map +0 -7
  180. package/dist/types/src/util/formatting.d.ts +0 -15
  181. package/dist/types/src/util/formatting.d.ts.map +0 -1
  182. package/src/util/formatting.ts +0 -171
@@ -7,7 +7,7 @@ import { type EditorView } from '@codemirror/view';
7
7
  import React, { forwardRef, useCallback, useEffect, useMemo, useRef } from 'react';
8
8
 
9
9
  import { type Database, Filter, Obj, type Type } from '@dxos/echo';
10
- import { useQuery } from '@dxos/react-client/echo';
10
+ import { useQuery } from '@dxos/echo-react';
11
11
  import { type ThemedClassName, useThemeContext, useTranslation } from '@dxos/react-ui';
12
12
  import {
13
13
  Editor,
@@ -17,7 +17,7 @@ import {
17
17
  type UseEditorMenuProps,
18
18
  } from '@dxos/react-ui-editor';
19
19
  import { createBasicExtensions, createThemeExtensions, insertAtCursor, keymap } from '@dxos/ui-editor';
20
- import { getHashHue } from '@dxos/ui-theme';
20
+ import { getHashHue, mx } from '@dxos/ui-theme';
21
21
 
22
22
  import { translationKey } from '#translations';
23
23
 
@@ -86,6 +86,7 @@ export const RefEditor = forwardRef<EditorController, RefEditorProps>(
86
86
  placeholder: placeholderProp,
87
87
  activateOnTyping,
88
88
  numItems = 8,
89
+ classNames,
89
90
  ...props
90
91
  },
91
92
  forwardedRef,
@@ -131,7 +132,13 @@ export const RefEditor = forwardRef<EditorController, RefEditorProps>(
131
132
 
132
133
  const getMenu = useCallback<NonNullable<UseEditorMenuProps['getMenu']>>(
133
134
  async ({ text }) => {
134
- const query = (text ?? '').replace(/^@/, '').toLowerCase();
135
+ // With `activateOnTyping` (no `@` trigger) an empty query would otherwise match every object
136
+ // and anchor the popover at the viewport origin (no range to position against). Show nothing
137
+ // until the user types. An explicit `@` trigger still browses (its `text` is '@', not empty).
138
+ if (!text) {
139
+ return [];
140
+ }
141
+ const query = text.replace(/^@/, '').toLowerCase();
135
142
  const matchesQuery = (object: Obj.Unknown) =>
136
143
  !query ||
137
144
  getObjectLabel(object).toLowerCase().includes(query) ||
@@ -199,7 +206,17 @@ export const RefEditor = forwardRef<EditorController, RefEditorProps>(
199
206
  const extensions = useMemo<Extension[]>(
200
207
  () => [
201
208
  createBasicExtensions({ readOnly: readonly, lineWrapping: false }),
202
- createThemeExtensions({ themeMode, slots: { scroller: { className: 'scrollbar-none' } } }),
209
+ createThemeExtensions({
210
+ themeMode,
211
+ slots: {
212
+ editor: {
213
+ className: 'w-full',
214
+ },
215
+ scroller: {
216
+ className: 'scrollbar-none',
217
+ },
218
+ },
219
+ }),
203
220
  refEditor({ mode, match, getRef: (id) => refsRef.current.get(id) }),
204
221
  Prec.highest(
205
222
  keymap.of([
@@ -225,6 +242,26 @@ export const RefEditor = forwardRef<EditorController, RefEditorProps>(
225
242
  return true;
226
243
  },
227
244
  },
245
+ {
246
+ key: 'Space',
247
+ // In email mode, a space after a bare address commits it (a tag only forms once
248
+ // committed). A partial `Name <…` segment still needs spaces, so only commit — and
249
+ // consume the space — when the whole in-progress segment is an address.
250
+ run: (view) => {
251
+ if (mode !== 'email') {
252
+ return false;
253
+ }
254
+ const { head } = view.state.selection.main;
255
+ const line = view.state.doc.lineAt(head);
256
+ const before = line.text.slice(0, head - line.from);
257
+ const segment = before.slice(before.lastIndexOf(',') + 1).trim();
258
+ if ((match ?? EMAIL_REGEX).test(segment)) {
259
+ insertAtCursor(view, head, ', ');
260
+ return true;
261
+ }
262
+ return false;
263
+ },
264
+ },
228
265
  ]),
229
266
  ),
230
267
  ],
@@ -245,9 +282,8 @@ export const RefEditor = forwardRef<EditorController, RefEditorProps>(
245
282
  ref={composedRef}
246
283
  >
247
284
  <Editor.View
248
- // TOOD(burdon): Use same style as react-ui Input.
249
- classNames='border border-input-separator rounded-xs px-2'
250
285
  {...props}
286
+ classNames={mx('flex items-center dx-input h-[2rem]', classNames)}
251
287
  initialValue={value}
252
288
  selectionEnd
253
289
  />
@@ -2,7 +2,14 @@
2
2
  // Copyright 2026 DXOS.org
3
3
  //
4
4
 
5
- import { type EditorState, type Extension, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
5
+ import {
6
+ EditorState,
7
+ type Extension,
8
+ RangeSetBuilder,
9
+ StateEffect,
10
+ StateField,
11
+ type TransactionSpec,
12
+ } from '@codemirror/state';
6
13
  import { Decoration, type DecorationSet, EditorView, WidgetType } from '@codemirror/view';
7
14
 
8
15
  import { Domino } from '@dxos/ui';
@@ -25,14 +32,6 @@ export type RefInfo = {
25
32
  */
26
33
  export type RefEditorMode = 'ref' | 'email';
27
34
 
28
- export type RefEditorExtensionOptions = {
29
- mode?: RefEditorMode;
30
- /** Resolve an object id referenced as `@<id>` to display info. */
31
- getRef?: (id: string) => RefInfo | undefined;
32
- /** Pluggable raw-token matcher (e.g. {@link EMAIL_REGEX}); matching tokens render as neutral tags. */
33
- match?: RegExp;
34
- };
35
-
36
35
  /** Matches an object reference token (`@<object id>`). */
37
36
  export const REF_REGEX = /^@([A-Za-z0-9]+)$/;
38
37
 
@@ -51,6 +50,15 @@ export const formatMailbox = (name: string | undefined, email: string): string =
51
50
  */
52
51
  export const refEditorRedecorate = StateEffect.define<null>();
53
52
 
53
+ export type RefEditorExtensionOptions = {
54
+ mode?: RefEditorMode;
55
+ /** Resolve an object id referenced as `@<id>` to display info. */
56
+ getRef?: (id: string) => RefInfo | undefined;
57
+ /** Pluggable raw-token matcher (e.g. {@link EMAIL_REGEX}); matching tokens render as neutral tags. */
58
+ match?: RegExp;
59
+ colorize?: boolean;
60
+ };
61
+
54
62
  /**
55
63
  * CodeMirror extension rendering reference-editor tokens as tags. Tags are atomic: the cursor
56
64
  * cannot enter them and deletion removes the whole token.
@@ -60,7 +68,44 @@ export const refEditorRedecorate = StateEffect.define<null>();
60
68
  * - `'email'` mode: RFC 5322 mailboxes render as tags labelled with the display name (falling
61
69
  * back to the address); the trailing comma separator is hidden inside the tag's atomic range.
62
70
  */
63
- export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOptions = {}): Extension => {
71
+ export const refEditor = ({ mode = 'ref', getRef, match, colorize }: RefEditorExtensionOptions = {}): Extension => {
72
+ // Forward declaration: the field is defined below but the widgets' delete affordance (built inside
73
+ // the field's `create`/`update`) needs to read it back at click time.
74
+ let decoField: StateField<DecorationSet>;
75
+
76
+ /** Delete the tag whose delete affordance (`node`) was clicked, keeping the delimited list well-formed. */
77
+ const removeToken = (view: EditorView, node: HTMLElement) => {
78
+ // Tags render in document order, so the button's index among all delete affordances selects the
79
+ // matching decoration. (A replace-widget's inner DOM has no reliable document position to query.)
80
+ const index = [...view.contentDOM.querySelectorAll('button[aria-label="Remove"]')].indexOf(node);
81
+ if (index < 0) {
82
+ return;
83
+ }
84
+ const cursor = view.state.field(decoField).iter();
85
+ let range: { from: number; to: number } | undefined;
86
+ for (let current = 0; cursor.value; current++, cursor.next()) {
87
+ if (current === index) {
88
+ range = { from: cursor.from, to: cursor.to };
89
+ break;
90
+ }
91
+ }
92
+ if (!range) {
93
+ return;
94
+ }
95
+
96
+ const doc = view.state.doc.toString();
97
+ let { from } = range;
98
+ const { to } = range;
99
+ if (mode === 'email' && !/,\s*$/.test(doc.slice(from, to))) {
100
+ // A last segment carries no trailing ", "; drop the one left dangling by the previous segment.
101
+ const dangling = doc.slice(0, from).match(/,\s*$/);
102
+ if (dangling) {
103
+ from -= dangling[0].length;
104
+ }
105
+ }
106
+ view.dispatch({ changes: { from, to }, selection: { anchor: from } });
107
+ };
108
+
64
109
  const buildRefDecorations = (state: EditorState) => {
65
110
  const deco = new RangeSetBuilder<Decoration>();
66
111
  const text = state.doc.toString();
@@ -78,9 +123,12 @@ export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOpt
78
123
  deco.add(
79
124
  from,
80
125
  to,
81
- Decoration.widget({
82
- widget: new RefWidget(ref.label, ref.hue ?? getHashHue(refMatch[1])),
83
- atomic: true,
126
+ Decoration.replace({
127
+ widget: new RefWidget(
128
+ ref.label,
129
+ ref.hue ?? (colorize ? getHashHue(refMatch[1]) : undefined),
130
+ removeToken,
131
+ ),
84
132
  }),
85
133
  );
86
134
  }
@@ -91,9 +139,8 @@ export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOpt
91
139
  deco.add(
92
140
  from,
93
141
  to,
94
- Decoration.widget({
95
- widget: new TokenWidget(token),
96
- atomic: true,
142
+ Decoration.replace({
143
+ widget: new RefWidget(token, undefined, removeToken),
97
144
  }),
98
145
  );
99
146
  }
@@ -117,6 +164,12 @@ export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOpt
117
164
  if (!content) {
118
165
  continue;
119
166
  }
167
+ // Only tag committed segments (those with a trailing comma). The final segment being typed has
168
+ // no comma yet, so a valid-looking address stays plain text until the user commits it (comma,
169
+ // space, or Enter) — otherwise the tag would pop in mid-typing.
170
+ if (commaIndex === -1) {
171
+ continue;
172
+ }
120
173
  const leading = segment.length - segment.trimStart().length;
121
174
  const from = segmentMatch.index + leading;
122
175
  const to = segmentMatch.index + segment.length;
@@ -126,18 +179,20 @@ export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOpt
126
179
  deco.add(
127
180
  from,
128
181
  to,
129
- Decoration.widget({
130
- widget: new RefWidget(nameAddr[1] || nameAddr[2], getHashHue(nameAddr[2])),
131
- atomic: true,
182
+ Decoration.replace({
183
+ widget: new RefWidget(
184
+ nameAddr[1] || nameAddr[2],
185
+ colorize ? getHashHue(nameAddr[2]) : undefined,
186
+ removeToken,
187
+ ),
132
188
  }),
133
189
  );
134
190
  } else if (addrRegex.test(content)) {
135
191
  deco.add(
136
192
  from,
137
193
  to,
138
- Decoration.widget({
139
- widget: new TokenWidget(content),
140
- atomic: true,
194
+ Decoration.replace({
195
+ widget: new RefWidget(content, undefined, removeToken),
141
196
  }),
142
197
  );
143
198
  }
@@ -148,55 +203,105 @@ export const refEditor = ({ mode = 'ref', getRef, match }: RefEditorExtensionOpt
148
203
 
149
204
  const buildDecorations = mode === 'email' ? buildEmailDecorations : buildRefDecorations;
150
205
 
151
- return [
152
- styles,
153
- StateField.define<DecorationSet>({
154
- create: (state) => buildDecorations(state),
155
- update: (deco, tr) => {
156
- if (tr.docChanged || tr.effects.some((effect) => effect.is(refEditorRedecorate))) {
157
- return buildDecorations(tr.state);
206
+ decoField = StateField.define<DecorationSet>({
207
+ create: (state) => buildDecorations(state),
208
+ update: (deco, tr) => {
209
+ if (tr.docChanged || tr.effects.some((effect) => effect.is(refEditorRedecorate))) {
210
+ return buildDecorations(tr.state);
211
+ }
212
+
213
+ return deco;
214
+ },
215
+ provide: (field) => [
216
+ EditorView.decorations.from(field),
217
+ // Every tag is atomic: arrow motion skips the whole range and deletion removes the whole token.
218
+ EditorView.atomicRanges.of((view) => view.state.field(field)),
219
+ ],
220
+ });
221
+
222
+ const separator = mode === 'email' ? ', ' : ' ';
223
+
224
+ // Typing immediately before a tag would otherwise re-tokenize into it (e.g. `x` + `bob@e.com`
225
+ // parses as the single email `xbob@e.com`); inject the delimiter after the inserted text so the
226
+ // new characters begin their own token (and open a fresh completion) rather than joining the tag.
227
+ const separatorFilter = EditorState.transactionFilter.of((tr) => {
228
+ if (!tr.docChanged || !tr.isUserEvent('input')) {
229
+ return tr;
230
+ }
231
+ const tags = tr.startState.field(decoField, false);
232
+ if (!tags) {
233
+ return tr;
234
+ }
235
+
236
+ let extra: TransactionSpec | undefined;
237
+ tr.changes.iterChanges((fromA, toA, _fromB, toB, inserted) => {
238
+ if (extra || fromA !== toA) {
239
+ return;
240
+ }
241
+ const text = inserted.toString();
242
+ if (!text || /[\s,]$/.test(text)) {
243
+ return;
244
+ }
245
+ // A tag starting exactly at the insertion point means the new text abuts it.
246
+ let abutsTag = false;
247
+ for (const cursor = tags.iter(); cursor.value; cursor.next()) {
248
+ if (cursor.from === fromA) {
249
+ abutsTag = true;
250
+ break;
158
251
  }
252
+ if (cursor.from > fromA) {
253
+ break;
254
+ }
255
+ }
256
+ if (abutsTag) {
257
+ extra = { changes: { from: toB, insert: separator }, sequential: true, selection: { anchor: toB } };
258
+ }
259
+ });
260
+
261
+ return extra ? [tr, extra] : tr;
262
+ });
159
263
 
160
- return deco;
161
- },
162
- provide: (field) => [
163
- EditorView.decorations.from(field),
164
- EditorView.atomicRanges.of((view) => {
165
- const builder = new RangeSetBuilder<Decoration>();
166
- const cursor = view.state.field(field).iter();
167
- while (cursor.value) {
168
- if (cursor.value.spec.atomic) {
169
- builder.add(cursor.from, cursor.to, cursor.value);
170
- }
171
- cursor.next();
172
- }
173
-
174
- return builder.finish();
175
- }),
176
- ],
177
- }),
178
- ];
264
+ return [styles, decoField, separatorFilter];
179
265
  };
180
266
 
181
- const lineHeight = '30px';
267
+ /** Delete an atomic tag given the DOM node of its widget. */
268
+ type RemoveToken = (view: EditorView, node: HTMLElement) => void;
182
269
 
183
270
  /**
184
- * NOTE: The outer container vertically aligns the inner text with content in the outer div.
271
+ * The outer span vertically aligns the tag with the surrounding line and adds a trailing gap so
272
+ * adjacent tags don't touch. The inner span carries the border; `overflow-hidden` clips the child
273
+ * fills to its radius (a square-cornered fill otherwise peeks past the rounded border).
185
274
  */
186
275
  const container = (classNames: string, ...children: Domino<HTMLElement>[]) => {
187
276
  const inner = Domino.of('span')
188
- .classNames(mx('inline-flex h-[26px] border rounded-xs', classNames))
277
+ .classNames(mx('inline-flex items-stretch border rounded-xs overflow-hidden', classNames))
189
278
  .append(...children);
190
- return Domino.of('span').classNames('inline-flex h-[28px] align-middle').append(inner).root;
279
+ return Domino.of('span').classNames('inline-flex align-middle pe-1').append(inner).root;
280
+ };
281
+
282
+ /** Trailing `x` affordance that deletes the tag it belongs to. */
283
+ const deleteButton = (view: EditorView, remove: RemoveToken) => {
284
+ const button = Domino.of('button')
285
+ .classNames('flex items-center px-1 text-description hover:text-error cursor-pointer')
286
+ .attributes({ 'type': 'button', 'aria-label': 'Remove' })
287
+ .append(Domino.svg('ph--x--bold').classNames('shrink-0 w-3.5 h-3.5'))
288
+ // `mousedown` + preventDefault so clicking the affordance does not move the editor selection.
289
+ .on('mousedown', (event) => {
290
+ event.preventDefault();
291
+ remove(view, button.root);
292
+ });
293
+ return button;
191
294
  };
192
295
 
193
296
  /**
194
- * Resolved reference (object reference or RFC 5322 mailbox), rendered as the object's label.
297
+ * Resolved reference (object reference or RFC 5322 mailbox), rendered as the object's label with a
298
+ * trailing delete affordance.
195
299
  */
196
300
  class RefWidget extends WidgetType {
197
301
  constructor(
198
302
  private readonly _label: string,
199
- private readonly _hue: string,
303
+ private readonly _hue: string | undefined,
304
+ private readonly _remove: RemoveToken,
200
305
  ) {
201
306
  super();
202
307
  }
@@ -205,50 +310,22 @@ class RefWidget extends WidgetType {
205
310
  return this._label === other._label && this._hue === other._hue;
206
311
  }
207
312
 
208
- override toDOM() {
209
- const { bg: fill, border, surface } = getStyles(this._hue);
210
- return container(
211
- border,
212
- Domino.of('span').classNames(mx('flex items-center px-1 text-black text-xs', fill)).text('@'),
213
- Domino.of('span')
214
- .classNames(mx('flex items-center px-1 text-subdued text-sm rounded-r-[3px]', surface))
215
- .text(this._label),
216
- );
217
- }
218
- }
219
-
220
- /**
221
- * Raw token accepted by the pluggable matcher (e.g. an email address).
222
- */
223
- class TokenWidget extends WidgetType {
224
- constructor(private readonly _token: string) {
225
- super();
226
- }
227
-
228
- override eq(other: this) {
229
- return this._token === other._token;
230
- }
231
-
232
- override toDOM() {
313
+ override toDOM(view: EditorView) {
314
+ const styles = this._hue ? getStyles(this._hue) : undefined;
233
315
  return container(
234
- 'border-separator',
235
- Domino.of('span').classNames('flex items-center px-1 text-subdued text-sm').text(this._token),
316
+ mx('border-separator rounded-sm', styles?.border),
317
+ Domino.of('span').classNames('flex items-center ps-2 pe-1').text(this._label),
318
+ deleteButton(view, this._remove),
236
319
  );
237
320
  }
238
321
  }
239
322
 
240
323
  const styles = EditorView.theme({
324
+ // Single-line editor: lay the line out as a centered flex row so text runs and the atomic tag
325
+ // widgets share a vertical center (avoids `vertical-align` baseline guesswork between the two).
241
326
  '.cm-line': {
242
- lineHeight,
243
- },
244
- // Match the standard Input block size (md density): 2.5rem, 2rem on pointer-fine devices.
245
- '.cm-scroller': {
327
+ padding: '0',
328
+ display: 'flex',
246
329
  alignItems: 'center',
247
- minHeight: '2.5rem',
248
- },
249
- '@media (pointer: fine)': {
250
- '.cm-scroller': {
251
- minHeight: '2rem',
252
- },
253
330
  },
254
331
  });
@@ -7,9 +7,9 @@ import * as Schema from 'effect/Schema';
7
7
  import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
8
8
 
9
9
  import { DXN, EID, Filter, JsonSchema, Obj, Query, type QueryAST, Scope, Tag, Type, type View } from '@dxos/echo';
10
+ import { useQuery } from '@dxos/echo-react';
10
11
  import { Format } from '@dxos/echo/Format';
11
12
  import { type Mutable } from '@dxos/echo/Obj';
12
- import { useQuery } from '@dxos/react-client/echo';
13
13
  import { useClientStory, withClientProvider } from '@dxos/react-client/testing';
14
14
  import { useAsyncEffect } from '@dxos/react-ui';
15
15
  import { Loading, withLayout, withTheme } from '@dxos/react-ui/testing';
@@ -23,9 +23,9 @@ import {
23
23
  Type,
24
24
  View,
25
25
  } from '@dxos/echo';
26
+ import { useObject, useQuery } from '@dxos/echo-react';
26
27
  import { SchemaEx } from '@dxos/effect';
27
28
  import { invariant } from '@dxos/invariant';
28
- import { useObject, useQuery } from '@dxos/react-client/echo';
29
29
  import { Input, Message, type ThemedClassName, ToggleIconButton, useTranslation } from '@dxos/react-ui';
30
30
  import { QueryForm, type QueryFormProps } from '@dxos/react-ui-components';
31
31
  import { OrderedList } from '@dxos/react-ui-list';
@@ -107,7 +107,7 @@ export const ViewEditor = forwardRef<ProjectionModel | null, ViewEditorProps>(
107
107
  projectionModel,
108
108
  ]);
109
109
 
110
- const queueTarget = Match.value(view.query.ast).pipe(
110
+ const feedTarget = Match.value(view.query.ast).pipe(
111
111
  Match.when({ type: 'from' }, ({ from }) => {
112
112
  if (from._tag !== 'scope') {
113
113
  return undefined;
@@ -125,16 +125,16 @@ export const ViewEditor = forwardRef<ProjectionModel | null, ViewEditorProps>(
125
125
  const feeds = useQuery(db, Filter.type(Feed.Feed));
126
126
 
127
127
  const targetRef = useMemo(() => {
128
- if (!queueTarget) {
128
+ if (!feedTarget) {
129
129
  return undefined;
130
130
  }
131
- const targetEid = EID.tryParse(queueTarget);
131
+ const targetEid = EID.tryParse(feedTarget);
132
132
  const feed = feeds.find((feed) => {
133
- const feedEid = Feed.getQueueUri(feed);
133
+ const feedEid = Feed.getFeedUri(feed);
134
134
  return feedEid != null && targetEid != null && EID.equals(feedEid, targetEid);
135
135
  });
136
136
  return feed ? Ref.fromURI(Entity.getURI(feed)) : undefined;
137
- }, [queueTarget, feeds]);
137
+ }, [feedTarget, feeds]);
138
138
 
139
139
  const viewSchema = useMemo(() => {
140
140
  const base = Schema.Struct({
@@ -179,13 +179,13 @@ export const ViewEditor = forwardRef<ProjectionModel | null, ViewEditorProps>(
179
179
  const handleUpdate = useCallback(
180
180
  (values: any) => {
181
181
  const targetValue = values.target;
182
- let queueDxn: EID.EID | undefined;
182
+ let feedDxn: EID.EID | undefined;
183
183
 
184
184
  if (Ref.isRef(targetValue)) {
185
185
  const feedUri = targetValue.uri;
186
186
  const feed = feeds.find((feed) => Obj.getURI(feed) === feedUri);
187
187
  if (feed) {
188
- queueDxn = Feed.getQueueUri(feed);
188
+ feedDxn = Feed.getFeedUri(feed);
189
189
  }
190
190
  }
191
191
 
@@ -197,7 +197,7 @@ export const ViewEditor = forwardRef<ProjectionModel | null, ViewEditorProps>(
197
197
  ? Query.select(Filter.type(EID.isEID(values.query) ? (values.query as EID.EID) : DXN.make(values.query)))
198
198
  .ast
199
199
  : JSON.parse(JSON.stringify(values.query));
200
- onQueryChanged?.(query, queueDxn);
200
+ onQueryChanged?.(query, feedDxn);
201
201
  },
202
202
  [onQueryChanged, mode, feeds],
203
203
  );
@@ -0,0 +1,85 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { act, renderHook } from '@testing-library/react';
6
+ import * as Schema from 'effect/Schema';
7
+ import { describe, test } from 'vitest';
8
+
9
+ import { useFormHandler } from './useFormHandler';
10
+
11
+ const schema = Schema.mutable(
12
+ Schema.Struct({
13
+ name: Schema.NonEmptyString,
14
+ city: Schema.String,
15
+ }),
16
+ );
17
+ type Values = Schema.Schema.Type<typeof schema>;
18
+
19
+ // Only the AST `_tag` matters to `onValueChange` (it special-cases numbers), so a plain string AST is enough here.
20
+ const stringAst = Schema.String.ast;
21
+
22
+ describe('useFormHandler reactive buffering', () => {
23
+ test('adopts external changes to fields the user is not editing', ({ expect }) => {
24
+ const { result, rerender } = renderHook(({ values }) => useFormHandler<Values>({ schema, values }), {
25
+ initialProps: { values: { name: 'Alice', city: 'NYC' } as Partial<Values> },
26
+ });
27
+ expect(result.current.getValue(['city'])).toBe('NYC');
28
+
29
+ act(() => rerender({ values: { name: 'Alice', city: 'LA' } }));
30
+ expect(result.current.getValue(['city'])).toBe('LA');
31
+ });
32
+
33
+ test('holds an invalid intermediate edit without snapping back while the source updates another field', ({
34
+ expect,
35
+ }) => {
36
+ const { result, rerender } = renderHook(({ values }) => useFormHandler<Values>({ schema, values }), {
37
+ initialProps: { values: { name: 'Alice', city: 'NYC' } as Partial<Values> },
38
+ });
39
+
40
+ // Type an invalid (empty) name. A parent that gates on validity would not persist it, so the source is unchanged.
41
+ act(() => result.current.onValueChange(['name'], stringAst, ''));
42
+ expect(result.current.getValue(['name'])).toBe('');
43
+ expect(result.current.isValid).toBe(false);
44
+
45
+ // The source updates a different field; the invalid draft must survive and the other field must update.
46
+ act(() => rerender({ values: { name: 'Alice', city: 'LA' } }));
47
+ expect(result.current.getValue(['name'])).toBe('');
48
+ expect(result.current.getValue(['city'])).toBe('LA');
49
+ });
50
+
51
+ test('reconciles an edited field once the source catches up to the committed value', ({ expect }) => {
52
+ const { result, rerender } = renderHook(({ values }) => useFormHandler<Values>({ schema, values }), {
53
+ initialProps: { values: { name: 'Alice', city: 'NYC' } as Partial<Values> },
54
+ });
55
+
56
+ // A valid edit is held locally until the source reflects it.
57
+ act(() => result.current.onValueChange(['name'], stringAst, 'Bob'));
58
+ expect(result.current.getValue(['name'])).toBe('Bob');
59
+
60
+ // Source now carries the committed value: the field reconciles and becomes valid again.
61
+ act(() => rerender({ values: { name: 'Bob', city: 'NYC' } }));
62
+ expect(result.current.getValue(['name'])).toBe('Bob');
63
+ expect(result.current.isValid).toBe(true);
64
+
65
+ // A subsequent external change to that same field is adopted (it is no longer treated as a pending edit).
66
+ act(() => rerender({ values: { name: 'Carol', city: 'NYC' } }));
67
+ expect(result.current.getValue(['name'])).toBe('Carol');
68
+ });
69
+
70
+ test('keeps an in-progress edit even when the source changes that same field (no snap-back)', ({ expect }) => {
71
+ const { result, rerender } = renderHook(({ values }) => useFormHandler<Values>({ schema, values }), {
72
+ initialProps: { values: { name: 'Alice', city: 'NYC' } as Partial<Values> },
73
+ });
74
+
75
+ // Start editing a field...
76
+ act(() => result.current.onValueChange(['name'], stringAst, 'Bob'));
77
+ expect(result.current.getValue(['name'])).toBe('Bob');
78
+
79
+ // ...the source changes that same field concurrently: the in-progress edit is kept (last-writer-wins on commit),
80
+ // while an un-edited field still adopts the source. Wholesale object swaps are handled by remounting the form.
81
+ act(() => rerender({ values: { name: 'Dave', city: 'SF' } }));
82
+ expect(result.current.getValue(['name'])).toBe('Bob');
83
+ expect(result.current.getValue(['city'])).toBe('SF');
84
+ });
85
+ });