@dxos/react-ui-form 0.8.4-main.dedc0f3 → 0.8.4-main.e8ec1fe

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 (89) hide show
  1. package/dist/lib/browser/index.mjs +480 -300
  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 +480 -300
  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/components/ControlSection/ControlSection.stories.d.ts +5 -5
  8. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +1 -1
  9. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  10. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +5 -5
  11. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  12. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -1
  13. package/dist/types/src/components/Form/Form.d.ts +4 -3
  14. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/Form.stories.d.ts +15 -15
  16. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/FormContent.d.ts +5 -5
  18. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/FormContext.d.ts +2 -2
  20. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/Input.d.ts +1 -1
  22. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/RefField.d.ts +10 -6
  24. package/dist/types/src/components/Form/RefField.d.ts.map +1 -1
  25. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +5 -5
  26. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -1
  27. package/dist/types/src/components/Form/factory.d.ts +1 -1
  28. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  29. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +41 -0
  30. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -0
  31. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +81 -0
  32. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -0
  33. package/dist/types/src/components/ObjectPicker/index.d.ts +2 -0
  34. package/dist/types/src/components/ObjectPicker/index.d.ts.map +1 -0
  35. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +20 -7
  36. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  37. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +10 -10
  38. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  39. package/dist/types/src/components/index.d.ts +1 -0
  40. package/dist/types/src/components/index.d.ts.map +1 -1
  41. package/dist/types/src/hooks/useForm.d.ts +2 -2
  42. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  43. package/dist/types/src/hooks/useQueryRefOptions.d.ts +9 -3
  44. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +1 -1
  45. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +1 -1
  46. package/dist/types/src/translations.d.ts +4 -4
  47. package/dist/types/src/util/field.d.ts +1 -1
  48. package/dist/types/src/util/field.d.ts.map +1 -1
  49. package/dist/types/src/util/formatting.d.ts +1 -1
  50. package/dist/types/src/util/formatting.d.ts.map +1 -1
  51. package/dist/types/src/util/refs.d.ts +1 -1
  52. package/dist/types/src/util/refs.d.ts.map +1 -1
  53. package/dist/types/src/util/schema.d.ts +1 -1
  54. package/dist/types/src/util/schema.d.ts.map +1 -1
  55. package/dist/types/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +40 -39
  57. package/src/components/ControlSection/ControlSection.stories.tsx +4 -5
  58. package/src/components/ControlSection/ControlSection.tsx +1 -1
  59. package/src/components/FieldEditor/FieldEditor.stories.tsx +6 -5
  60. package/src/components/FieldEditor/FieldEditor.tsx +5 -2
  61. package/src/components/Form/ArrayField.tsx +4 -3
  62. package/src/components/Form/Defaults.tsx +1 -1
  63. package/src/components/Form/Form.stories.tsx +35 -35
  64. package/src/components/Form/Form.tsx +16 -6
  65. package/src/components/Form/FormActions.tsx +2 -2
  66. package/src/components/Form/FormContent.tsx +82 -22
  67. package/src/components/Form/FormContext.tsx +3 -3
  68. package/src/components/Form/Input.tsx +1 -1
  69. package/src/components/Form/RefField.tsx +48 -64
  70. package/src/components/Form/custom/GeoPointInput.tsx +1 -1
  71. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +5 -6
  72. package/src/components/Form/custom/SelectOptionsInput.tsx +2 -2
  73. package/src/components/Form/factory.tsx +1 -1
  74. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +207 -0
  75. package/src/components/ObjectPicker/ObjectPicker.tsx +161 -0
  76. package/src/components/ObjectPicker/index.ts +5 -0
  77. package/src/components/ViewEditor/ViewEditor.stories.tsx +107 -59
  78. package/src/components/ViewEditor/ViewEditor.test.tsx +6 -5
  79. package/src/components/ViewEditor/ViewEditor.tsx +275 -231
  80. package/src/components/index.ts +1 -0
  81. package/src/components/testing.tsx +2 -2
  82. package/src/hooks/useForm.ts +7 -4
  83. package/src/hooks/useQueryRefOptions.ts +15 -6
  84. package/src/hooks/useRefQueryLookupHandler.ts +3 -3
  85. package/src/translations.ts +4 -4
  86. package/src/util/field.ts +1 -1
  87. package/src/util/formatting.ts +1 -1
  88. package/src/util/refs.ts +7 -7
  89. package/src/util/schema.ts +2 -4
@@ -2,29 +2,31 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { type SchemaAST } from 'effect';
6
- import React, { useCallback, useMemo, useState } from 'react';
5
+ import '@dxos/lit-ui/dx-tag-picker.pcss';
6
+
7
+ import type * as Schema from 'effect/Schema';
8
+ import type * as SchemaAST from 'effect/SchemaAST';
9
+ import React, { useCallback, useMemo } from 'react';
7
10
 
8
11
  import {
12
+ type EchoSchema,
9
13
  Expando,
10
14
  Ref,
11
15
  ReferenceAnnotationId,
12
16
  type ReferenceAnnotationValue,
13
- type TypeAnnotation,
14
17
  getTypeAnnotation,
15
- } from '@dxos/echo-schema';
18
+ } from '@dxos/echo/internal';
16
19
  import { findAnnotation } from '@dxos/effect';
17
20
  import { DXN } from '@dxos/keys';
18
21
  import { type DxTagPickerItemClick } from '@dxos/lit-ui';
19
- import { DxAnchor } from '@dxos/lit-ui/react';
22
+ import { DxAnchor, DxTagPickerItem } from '@dxos/lit-ui/react';
20
23
  import { Button, Icon, Input, useTranslation } from '@dxos/react-ui';
21
- import { PopoverCombobox } from '@dxos/react-ui-searchlist';
22
- import { TagPickerItem } from '@dxos/react-ui-tag-picker';
23
24
  import { descriptionText, mx } from '@dxos/react-ui-theme';
24
- import { type MaybePromise, isNonNullable } from '@dxos/util';
25
+ import { isNonNullable } from '@dxos/util';
25
26
 
26
27
  import { type QueryRefOptions, useQueryRefOptions } from '../../hooks';
27
28
  import { translationKey } from '../../translations';
29
+ import { ObjectPicker } from '../ObjectPicker';
28
30
 
29
31
  import { TextInput } from './Defaults';
30
32
  import { InputHeader, type InputProps } from './Input';
@@ -32,10 +34,13 @@ import { InputHeader, type InputProps } from './Input';
32
34
  export type RefFieldProps = InputProps & {
33
35
  ast?: SchemaAST.AST;
34
36
  array?: boolean;
35
- onQueryRefOptions?: QueryRefOptions;
36
37
  createOptionLabel?: [string, { ns: string }];
37
38
  createOptionIcon?: string;
38
- onCreateFromQuery?: (type: TypeAnnotation, query: string) => MaybePromise<void>;
39
+ onCreate?: (values: any) => void;
40
+ createSchema?: Schema.Schema.AnyNoContext;
41
+ createInitialValuePath?: string;
42
+ onQueryRefOptions?: QueryRefOptions;
43
+ schema?: EchoSchema;
39
44
  };
40
45
 
41
46
  // TODO(thure): Is this a standard that should be better canonized?
@@ -52,11 +57,13 @@ export const RefField = ({
52
57
  array,
53
58
  ast,
54
59
  getValue,
55
- onBlur,
56
- onQueryRefOptions,
57
60
  createOptionLabel,
58
61
  createOptionIcon,
59
- onCreateFromQuery,
62
+ onBlur,
63
+ onCreate,
64
+ createSchema,
65
+ createInitialValuePath,
66
+ onQueryRefOptions,
60
67
  onValueChange,
61
68
  ...restInputProps
62
69
  }: RefFieldProps) => {
@@ -65,7 +72,11 @@ export const RefField = ({
65
72
  () => (ast ? findAnnotation<ReferenceAnnotationValue>(ast, ReferenceAnnotationId) : undefined),
66
73
  [ast],
67
74
  );
68
- const { options: availableOptions, loading: _loading } = useQueryRefOptions({ refTypeInfo, onQueryRefOptions });
75
+ const {
76
+ options: availableOptions,
77
+ update: updateOptions,
78
+ loading: _loading,
79
+ } = useQueryRefOptions({ refTypeInfo, onQueryRefOptions });
69
80
 
70
81
  if ((refTypeInfo && refTypeInfo?.typename === getTypeAnnotation(Expando)?.typename) || !onQueryRefOptions) {
71
82
  // If ref type is expando, fall back to taking a DXN in string format.
@@ -130,6 +141,14 @@ export const RefField = ({
130
141
  [availableOptions, array, onValueChange],
131
142
  );
132
143
 
144
+ const handleCreate = useCallback(
145
+ (values: any) => {
146
+ onCreate?.(values);
147
+ updateOptions();
148
+ },
149
+ [onCreate, updateOptions],
150
+ );
151
+
133
152
  if (!refTypeInfo) {
134
153
  return null;
135
154
  }
@@ -138,15 +157,6 @@ export const RefField = ({
138
157
 
139
158
  const items = handleGetValue();
140
159
  const selectedIds = useMemo(() => items.map((i: any) => i.id), [items]);
141
- const labelById = useMemo(
142
- () =>
143
- availableOptions.reduce((acc: Record<string, string>, option) => {
144
- acc[option.id.toLowerCase()] = option.label.toLowerCase();
145
- return acc;
146
- }, {}),
147
- [availableOptions],
148
- );
149
- const [query, setQuery] = useState('');
150
160
  const toggleSelect = useCallback(
151
161
  (id: string) => {
152
162
  if (array) {
@@ -179,13 +189,13 @@ export const RefField = ({
179
189
  ))
180
190
  )
181
191
  ) : (
182
- <PopoverCombobox.Root>
183
- <PopoverCombobox.Trigger asChild>
192
+ <ObjectPicker.Root>
193
+ <ObjectPicker.Trigger asChild>
184
194
  <Button variant='ghost' classNames='is-full text-start gap-2'>
185
195
  <div role='none' className='grow'>
186
196
  {items?.length ? (
187
197
  items?.map((item) => (
188
- <TagPickerItem
198
+ <DxTagPickerItem
189
199
  key={item.id}
190
200
  itemId={item.id}
191
201
  label={item.label}
@@ -206,44 +216,18 @@ export const RefField = ({
206
216
  </div>
207
217
  <Icon size={3} icon='ph--caret-down--bold' />
208
218
  </Button>
209
- </PopoverCombobox.Trigger>
210
- <PopoverCombobox.Content
211
- filter={(value, search) =>
212
- value === '__create__' || labelById[value]?.includes(search.toLowerCase()) ? 1 : 0
213
- }
214
- >
215
- <PopoverCombobox.Input
216
- placeholder={'Search...'}
217
- value={query}
218
- onValueChange={(v) => setQuery(v)}
219
- autoFocus
220
- />
221
- <PopoverCombobox.List>
222
- {availableOptions.map((option) => (
223
- <PopoverCombobox.Item
224
- key={option.id}
225
- value={option.id}
226
- onSelect={() => toggleSelect(option.id)}
227
- classNames='flex items-center gap-2'
228
- >
229
- <span className='grow'>{option.label}</span>
230
- {selectedIds.includes(option.id) && <Icon icon='ph--check--regular' />}
231
- </PopoverCombobox.Item>
232
- ))}
233
- {query.length > 0 && createOptionLabel && createOptionIcon && onCreateFromQuery && (
234
- <PopoverCombobox.Item
235
- value='__create__'
236
- onSelect={() => onCreateFromQuery?.(refTypeInfo, query)}
237
- classNames='flex items-center gap-2'
238
- >
239
- <Icon icon={createOptionIcon} />
240
- {t(createOptionLabel[0], { ns: createOptionLabel[1].ns, text: query })}
241
- </PopoverCombobox.Item>
242
- )}
243
- </PopoverCombobox.List>
244
- <PopoverCombobox.Arrow />
245
- </PopoverCombobox.Content>
246
- </PopoverCombobox.Root>
219
+ </ObjectPicker.Trigger>
220
+ <ObjectPicker.Content
221
+ options={availableOptions}
222
+ selectedIds={selectedIds}
223
+ onSelect={toggleSelect}
224
+ createSchema={createSchema}
225
+ createOptionLabel={createOptionLabel}
226
+ createOptionIcon={createOptionIcon}
227
+ createInitialValuePath={createInitialValuePath}
228
+ onCreate={handleCreate}
229
+ />
230
+ </ObjectPicker.Root>
247
231
  )}
248
232
  </div>
249
233
  {inputOnly && <Input.DescriptionAndValidation>{error}</Input.DescriptionAndValidation>}
@@ -4,7 +4,7 @@
4
4
 
5
5
  import React, { type ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
6
6
 
7
- import { GeoLocation, type GeoPoint } from '@dxos/echo-schema';
7
+ import { GeoLocation, type GeoPoint } from '@dxos/echo/internal';
8
8
  import { Input, useTranslation } from '@dxos/react-ui';
9
9
  import { safeParseFloat } from '@dxos/util';
10
10
 
@@ -2,14 +2,12 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import '@dxos-theme';
6
-
7
5
  import { type Meta, type StoryObj } from '@storybook/react-vite';
8
- import { Schema } from 'effect';
6
+ import * as Schema from 'effect/Schema';
9
7
  import React, { useState } from 'react';
10
8
 
11
- import { type SelectOption, SelectOptionSchema } from '@dxos/echo-schema';
12
- import { withLayout, withTheme } from '@dxos/storybook-utils';
9
+ import { type SelectOption, SelectOptionSchema } from '@dxos/echo/internal';
10
+ import { withTheme } from '@dxos/react-ui/testing';
13
11
 
14
12
  import { translations } from '../../../translations';
15
13
  import { TestLayout, TestPanel } from '../../testing';
@@ -49,8 +47,9 @@ const meta = {
49
47
  title: 'ui/react-ui-form/SelectOptionsInput',
50
48
  component: SelectOptionInput as any,
51
49
  render: DefaultStory,
52
- decorators: [withTheme, withLayout({ fullscreen: true })],
50
+ decorators: [withTheme],
53
51
  parameters: {
52
+ layout: 'fullscreen',
54
53
  translations,
55
54
  },
56
55
  } satisfies Meta<typeof DefaultStory>;
@@ -4,7 +4,7 @@
4
4
 
5
5
  import React, { type ChangeEvent, type KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
6
6
 
7
- import { type SelectOption } from '@dxos/echo-schema';
7
+ import { type SelectOption } from '@dxos/echo/internal';
8
8
  import { PublicKey } from '@dxos/keys';
9
9
  import { type ChromaticPalette, IconButton, Input, Tag, useTranslation } from '@dxos/react-ui';
10
10
  import { List } from '@dxos/react-ui-list';
@@ -121,7 +121,7 @@ export const SelectOptionInput = ({ type, label, readonly, getStatus, getValue,
121
121
  readonly={!!readonly}
122
122
  >
123
123
  {({ items }) => (
124
- <div role='list' className='w-full overflow-auto'>
124
+ <div role='list' className='is-full overflow-auto'>
125
125
  {items?.map((item) => (
126
126
  <div key={item.id}>
127
127
  <List.Item
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { FormatEnum } from '@dxos/echo-schema';
5
+ import { FormatEnum } from '@dxos/echo/internal';
6
6
  import { type SimpleType } from '@dxos/effect';
7
7
 
8
8
  import { GeoPointInput } from './custom';
@@ -0,0 +1,207 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import * as Schema from 'effect/Schema';
7
+ import React, { useCallback, useEffect, useMemo, useState } from 'react';
8
+ import { expect, fn, userEvent, within } from 'storybook/test';
9
+
10
+ import { Obj } from '@dxos/echo';
11
+ import { faker } from '@dxos/random';
12
+ import { Filter, useQuery } from '@dxos/react-client/echo';
13
+ import { useClientProvider, withClientProvider } from '@dxos/react-client/testing';
14
+ import { Button } from '@dxos/react-ui';
15
+ import { withTheme } from '@dxos/react-ui/testing';
16
+ import { Person } from '@dxos/types';
17
+
18
+ import { translations } from '../../translations';
19
+
20
+ import { ObjectPicker } from './ObjectPicker';
21
+
22
+ faker.seed(1);
23
+
24
+ const createPerson = () =>
25
+ Obj.make(Person.Person, {
26
+ fullName: faker.person.fullName(),
27
+ });
28
+
29
+ const omitId = Schema.omit<any, any, ['id']>('id');
30
+ const personSchema = omitId(Person.Person);
31
+
32
+ // Mock functions for testing
33
+ const mockHandleSelect = fn();
34
+ const mockHandleCreate = fn();
35
+
36
+ const DefaultStory = () => {
37
+ const { space } = useClientProvider();
38
+ const [isOpen, setIsOpen] = useState(false);
39
+
40
+ // Create sample Person objects
41
+ useEffect(() => {
42
+ if (space) {
43
+ // Create some sample Person objects for testing
44
+ Array.from({ length: 10 }).forEach(() => {
45
+ space.db.add(createPerson());
46
+ });
47
+ }
48
+ }, [space]);
49
+
50
+ // Get all objects in the space (similar to BoardContainer)
51
+ const allObjects = useQuery(space, Filter.everything());
52
+
53
+ // Map objects to options format expected by ObjectPicker
54
+ const options = useMemo(
55
+ () =>
56
+ allObjects.map((obj) => ({
57
+ id: obj.id,
58
+ label: obj.fullName || obj.name || obj.title || obj.id,
59
+ hue: 'neutral' as const,
60
+ })),
61
+ [allObjects],
62
+ );
63
+
64
+ const handleCreateCallback = useCallback(
65
+ (values: any) => {
66
+ console.log('[on create]', values);
67
+ if (!space) return;
68
+ const newPerson = space.db.add(Obj.make(Person.Person, values));
69
+ mockHandleCreate(values);
70
+ return newPerson;
71
+ },
72
+ [space],
73
+ );
74
+
75
+ return (
76
+ <div className='p-4'>
77
+ <ObjectPicker.Root open={isOpen} onOpenChange={setIsOpen}>
78
+ <ObjectPicker.Trigger asChild>
79
+ <Button variant='primary' data-testid='trigger'>
80
+ Select Person
81
+ </Button>
82
+ </ObjectPicker.Trigger>
83
+ <ObjectPicker.Content
84
+ options={options}
85
+ onSelect={mockHandleSelect}
86
+ createSchema={personSchema}
87
+ createOptionLabel={['create new person label', { ns: 'os' }]}
88
+ createOptionIcon='ph--user-plus--regular'
89
+ createInitialValuePath='fullName'
90
+ onCreate={handleCreateCallback}
91
+ classNames='popover-card-width'
92
+ />
93
+ </ObjectPicker.Root>
94
+ </div>
95
+ );
96
+ };
97
+
98
+ const meta = {
99
+ title: 'ui/react-ui-form/ObjectPicker',
100
+ render: DefaultStory,
101
+ decorators: [
102
+ withTheme,
103
+ withClientProvider({
104
+ types: [Person.Person],
105
+ createIdentity: true,
106
+ createSpace: true,
107
+ }),
108
+ ],
109
+ parameters: {
110
+ layout: 'centered',
111
+ controls: { disable: true },
112
+ translations,
113
+ },
114
+ } satisfies Meta<typeof DefaultStory>;
115
+
116
+ export default meta;
117
+
118
+ type Story = StoryObj<typeof meta>;
119
+
120
+ export const Default: Story = {};
121
+
122
+ export const WithTest: Story = {
123
+ play: async ({ canvasElement }) => {
124
+ const canvas = within(canvasElement);
125
+ const body = within(document.body);
126
+
127
+ // Find and click the trigger button.
128
+ const triggerButton = await canvas.findByTestId('trigger', undefined, { timeout: 5000 });
129
+ await expect(triggerButton).toBeVisible();
130
+ await userEvent.click(triggerButton);
131
+
132
+ // Check that the listbox renders when the trigger is clicked.
133
+ const listbox = await body.findByRole('listbox', undefined, { timeout: 5000 });
134
+ await expect(listbox).toBeVisible();
135
+
136
+ // Find the search input.
137
+ const searchInput = await body.findByPlaceholderText('Search…');
138
+ await expect(searchInput).toBeVisible();
139
+
140
+ // Test searching filters the results - get initial options count.
141
+ const initialOptions = await body.findAllByRole('option');
142
+ const initialCount = initialOptions.length;
143
+ await expect(initialCount).toBeGreaterThan(0);
144
+
145
+ // Type a search term that should filter results.
146
+ await userEvent.type(searchInput, 'John');
147
+
148
+ // Wait a moment for filtering.
149
+ await new Promise((resolve) => setTimeout(resolve, 100));
150
+
151
+ // Check that results are filtered (should be fewer options)
152
+ const _filteredOptions = await body.findAllByRole('option');
153
+ // Note: We expect either fewer results or the same if no John exists
154
+
155
+ // Clear search and test selecting a result
156
+ await userEvent.clear(searchInput);
157
+
158
+ // Get the first option and click it
159
+ const updatedOptions = await body.findAllByRole('option');
160
+ const firstOption = updatedOptions[0];
161
+ await expect(firstOption).toBeVisible();
162
+
163
+ const _firstOptionText = firstOption.textContent;
164
+ await userEvent.click(firstOption);
165
+
166
+ // Check that clicking a result fires onSelect with that id
167
+ // The onSelect should have been called with the selected option's id
168
+ await expect(mockHandleSelect).toHaveBeenCalled();
169
+
170
+ // Re-open the picker to test create functionality.
171
+ await userEvent.click(triggerButton);
172
+
173
+ // Wait for listbox to appear again.
174
+ const newListbox = await body.findByRole('listbox', undefined, { timeout: 5000 });
175
+ await expect(newListbox).toBeVisible();
176
+
177
+ const newSearchInput = await body.findByPlaceholderText('Search…');
178
+
179
+ // Test searching an unrelated term provides the __create__ option.
180
+ const unrelatedTerm = 'ZxyUnrelatedName123';
181
+ await userEvent.type(newSearchInput, unrelatedTerm);
182
+
183
+ // Wait for the create option to appear.
184
+ await new Promise((resolve) => setTimeout(resolve, 200));
185
+
186
+ // Look for the create option.
187
+ const createOption = await body.findByRole('option', undefined, { timeout: 3000 });
188
+ await expect(createOption).toBeVisible();
189
+
190
+ // Click the create option - this should change state to the form field.
191
+ await userEvent.click(createOption);
192
+
193
+ // Check that clicking that option changes state to the form field.
194
+ const form = await body.findByTestId('create-referenced-object-form', undefined, { timeout: 5000 });
195
+ await expect(form).toBeVisible();
196
+
197
+ // Find and click the save button.
198
+ const saveButton = await within(form).findByTestId('save-button');
199
+ await expect(saveButton).toBeVisible();
200
+ await userEvent.click(saveButton);
201
+
202
+ // Check that clicking the save button calls onCreate with expected values.
203
+ await expect(mockHandleCreate).toHaveBeenCalledWith({
204
+ fullName: unrelatedTerm,
205
+ });
206
+ },
207
+ };
@@ -0,0 +1,161 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import type * as Schema from 'effect/Schema';
6
+ import React, { type KeyboardEvent, type MouseEvent, forwardRef, useCallback, useMemo, useState } from 'react';
7
+
8
+ import { Icon, Popover, type ThemedClassName, useTranslation } from '@dxos/react-ui';
9
+ import { Combobox } from '@dxos/react-ui-searchlist';
10
+
11
+ import { type QueryTag } from '../../hooks';
12
+ import { translationKey } from '../../translations';
13
+ import { Form } from '../Form';
14
+
15
+ export type ObjectPickerContentProps = ThemedClassName<{
16
+ options: QueryTag[];
17
+ selectedIds?: string[];
18
+ createSchema?: Schema.Schema.AnyNoContext;
19
+ createOptionLabel?: [string, { ns: string }];
20
+ createOptionIcon?: string;
21
+ createInitialValuePath?: string;
22
+ onSelect: (id: string) => void;
23
+ onCreate?: (values: any) => void;
24
+ }>;
25
+
26
+ const ObjectPickerContent = forwardRef<HTMLDivElement, ObjectPickerContentProps>(
27
+ (
28
+ {
29
+ options,
30
+ selectedIds,
31
+ createSchema,
32
+ createOptionLabel,
33
+ createOptionIcon,
34
+ createInitialValuePath,
35
+ onSelect,
36
+ onCreate,
37
+ ...props
38
+ },
39
+ ref,
40
+ ) => {
41
+ const { t } = useTranslation(translationKey);
42
+
43
+ const [searchString, setSearchString] = useState('');
44
+ const [showForm, setShowForm] = useState(false);
45
+
46
+ const handleFormSave = useCallback(
47
+ (values: any) => {
48
+ onCreate?.(values);
49
+ setShowForm(false);
50
+ setSearchString('');
51
+ },
52
+ [onCreate],
53
+ );
54
+
55
+ const handleFormCancel = useCallback(() => {
56
+ setShowForm(false);
57
+ setSearchString('');
58
+ }, []);
59
+
60
+ // TODO(thure): The following click and keydown handlers are necessary because `onSelect` is called after the
61
+ // Popover is already closed. Augment/refactor CmdK, if possible, to facilitate stopping event default
62
+ // and propagation.
63
+
64
+ const handleClick = useCallback(
65
+ (event: MouseEvent) => {
66
+ if (createSchema && (event.target as HTMLElement).closest('[data-value="__create__"]')) {
67
+ event.stopPropagation();
68
+ event.preventDefault();
69
+ setShowForm(true);
70
+ }
71
+ },
72
+ [createSchema],
73
+ );
74
+
75
+ const handleKeyDown = useCallback(
76
+ (event: KeyboardEvent) => {
77
+ if (
78
+ event.key === 'Enter' &&
79
+ createSchema &&
80
+ (event.currentTarget as HTMLElement).querySelector(
81
+ '[role="option"][aria-selected="true"][data-value="__create__"]',
82
+ )
83
+ ) {
84
+ event.stopPropagation();
85
+ event.preventDefault();
86
+ setShowForm(true);
87
+ }
88
+ },
89
+ [createSchema],
90
+ );
91
+
92
+ const labelById = useMemo(
93
+ () =>
94
+ options.reduce((acc: Record<string, string>, option) => {
95
+ acc[option.id.toLowerCase()] = option.label.toLowerCase();
96
+ return acc;
97
+ }, {}),
98
+ [options],
99
+ );
100
+
101
+ return (
102
+ <Combobox.Content
103
+ {...props}
104
+ ref={ref}
105
+ filter={(value, search) => (value === '__create__' || labelById[value]?.includes(search.toLowerCase()) ? 1 : 0)}
106
+ onClickCapture={handleClick}
107
+ onKeyDownCapture={handleKeyDown}
108
+ >
109
+ {showForm && createSchema ? (
110
+ <Popover.Viewport>
111
+ <Form
112
+ schema={createSchema}
113
+ values={createInitialValuePath ? { [createInitialValuePath]: searchString } : {}}
114
+ onSave={handleFormSave}
115
+ onCancel={handleFormCancel}
116
+ testId='create-referenced-object-form'
117
+ />
118
+ </Popover.Viewport>
119
+ ) : (
120
+ <>
121
+ <Combobox.Input
122
+ placeholder={t('ref field combobox input placeholder')}
123
+ value={searchString}
124
+ onValueChange={setSearchString}
125
+ autoFocus
126
+ />
127
+ <Combobox.List>
128
+ {options.map((option) => (
129
+ <Combobox.Item
130
+ key={option.id}
131
+ value={option.id}
132
+ onSelect={() => onSelect(option.id)}
133
+ classNames='flex items-center gap-2'
134
+ >
135
+ <span className='grow'>{option.label}</span>
136
+ {selectedIds?.includes(option.id) && <Icon icon='ph--check--regular' />}
137
+ </Combobox.Item>
138
+ ))}
139
+ {searchString.length > 0 && createOptionLabel && createOptionIcon && createSchema && onCreate && (
140
+ <Combobox.Item value='__create__' classNames='flex items-center gap-2'>
141
+ <Icon icon={createOptionIcon} />
142
+ {t(createOptionLabel[0], { ns: createOptionLabel[1].ns, text: searchString })}
143
+ </Combobox.Item>
144
+ )}
145
+ </Combobox.List>
146
+ </>
147
+ )}
148
+ <Combobox.Arrow />
149
+ </Combobox.Content>
150
+ );
151
+ },
152
+ );
153
+
154
+ ObjectPickerContent.displayName = 'ObjectPickerContent';
155
+
156
+ export const ObjectPicker = {
157
+ Root: Combobox.Root,
158
+ Trigger: Combobox.Trigger,
159
+ VirtualTrigger: Combobox.VirtualTrigger,
160
+ Content: ObjectPickerContent,
161
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './ObjectPicker';