@dxos/react-ui-form 0.8.4-main.b97322e → 0.8.4-main.c4373fc

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 (117) hide show
  1. package/dist/lib/browser/index.mjs +810 -594
  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 +810 -594
  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.d.ts +2 -2
  8. package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +1 -1
  9. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts +81 -5
  10. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +2 -2
  12. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  13. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts +76 -5
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/ArrayField.d.ts +2 -2
  16. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/Defaults.d.ts +5 -5
  18. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/Form.d.ts +7 -6
  20. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/Form.stories.d.ts +129 -32
  22. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  23. package/dist/types/src/components/Form/FormActions.d.ts.map +1 -1
  24. package/dist/types/src/components/Form/FormContent.d.ts +17 -11
  25. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
  26. package/dist/types/src/components/Form/FormContext.d.ts +3 -3
  27. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/Input.d.ts +5 -2
  29. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  30. package/dist/types/src/components/Form/RefField.d.ts +14 -5
  31. package/dist/types/src/components/Form/RefField.d.ts.map +1 -1
  32. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +1 -1
  33. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -1
  34. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +1 -1
  35. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +1 -1
  36. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +76 -4
  37. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -1
  38. package/dist/types/src/components/Form/custom/TupleInput.d.ts +1 -1
  39. package/dist/types/src/components/Form/factory.d.ts +1 -1
  40. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  41. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +41 -0
  42. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -0
  43. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +81 -0
  44. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -0
  45. package/dist/types/src/components/ObjectPicker/index.d.ts +2 -0
  46. package/dist/types/src/components/ObjectPicker/index.d.ts.map +1 -0
  47. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +16 -4
  48. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  49. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +79 -7
  50. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  51. package/dist/types/src/components/index.d.ts +1 -1
  52. package/dist/types/src/components/index.d.ts.map +1 -1
  53. package/dist/types/src/components/testing.d.ts +6 -3
  54. package/dist/types/src/components/testing.d.ts.map +1 -1
  55. package/dist/types/src/hooks/useForm.d.ts +3 -3
  56. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  57. package/dist/types/src/hooks/useQueryRefOptions.d.ts +8 -3
  58. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +1 -1
  59. package/dist/types/src/translations.d.ts +11 -5
  60. package/dist/types/src/translations.d.ts.map +1 -1
  61. package/dist/types/src/util/field.d.ts +1 -1
  62. package/dist/types/src/util/field.d.ts.map +1 -1
  63. package/dist/types/src/util/formatting.d.ts +1 -1
  64. package/dist/types/src/util/formatting.d.ts.map +1 -1
  65. package/dist/types/src/util/refs.d.ts +1 -1
  66. package/dist/types/src/util/refs.d.ts.map +1 -1
  67. package/dist/types/src/util/schema.d.ts +1 -1
  68. package/dist/types/src/util/schema.d.ts.map +1 -1
  69. package/dist/types/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +41 -36
  71. package/src/components/ControlSection/ControlSection.stories.tsx +11 -14
  72. package/src/components/ControlSection/ControlSection.tsx +26 -12
  73. package/src/components/FieldEditor/FieldEditor.stories.tsx +14 -14
  74. package/src/components/FieldEditor/FieldEditor.test.tsx +3 -2
  75. package/src/components/FieldEditor/FieldEditor.tsx +26 -18
  76. package/src/components/Form/ArrayField.tsx +12 -11
  77. package/src/components/Form/Defaults.tsx +22 -20
  78. package/src/components/Form/Form.stories.tsx +135 -99
  79. package/src/components/Form/Form.tsx +26 -15
  80. package/src/components/Form/FormActions.tsx +2 -1
  81. package/src/components/Form/FormContent.tsx +121 -39
  82. package/src/components/Form/FormContext.tsx +8 -8
  83. package/src/components/Form/Input.tsx +5 -2
  84. package/src/components/Form/RefField.tsx +97 -41
  85. package/src/components/Form/custom/GeoPointInput.tsx +29 -21
  86. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +11 -11
  87. package/src/components/Form/custom/SelectOptionsInput.tsx +30 -31
  88. package/src/components/Form/custom/TupleInput.tsx +2 -2
  89. package/src/components/Form/factory.tsx +2 -2
  90. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +207 -0
  91. package/src/components/ObjectPicker/ObjectPicker.tsx +161 -0
  92. package/src/components/ObjectPicker/index.ts +5 -0
  93. package/src/components/ViewEditor/ViewEditor.stories.tsx +102 -44
  94. package/src/components/ViewEditor/ViewEditor.test.tsx +9 -12
  95. package/src/components/ViewEditor/ViewEditor.tsx +328 -220
  96. package/src/components/index.ts +1 -2
  97. package/src/components/testing.tsx +15 -9
  98. package/src/hooks/useForm.ts +8 -4
  99. package/src/hooks/useQueryRefOptions.ts +9 -3
  100. package/src/hooks/useRefQueryLookupHandler.ts +1 -1
  101. package/src/translations.ts +13 -6
  102. package/src/util/field.ts +9 -19
  103. package/src/util/formatting.ts +1 -1
  104. package/src/util/refs.ts +2 -2
  105. package/src/util/schema.ts +2 -4
  106. package/dist/types/src/components/deprecated/Form.d.ts +0 -15
  107. package/dist/types/src/components/deprecated/Form.d.ts.map +0 -1
  108. package/dist/types/src/components/deprecated/Form.stories.d.ts +0 -13
  109. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +0 -1
  110. package/dist/types/src/components/deprecated/FormInput.d.ts +0 -16
  111. package/dist/types/src/components/deprecated/FormInput.d.ts.map +0 -1
  112. package/dist/types/src/components/deprecated/index.d.ts +0 -2
  113. package/dist/types/src/components/deprecated/index.d.ts.map +0 -1
  114. package/src/components/deprecated/Form.stories.tsx +0 -62
  115. package/src/components/deprecated/Form.tsx +0 -119
  116. package/src/components/deprecated/FormInput.tsx +0 -71
  117. package/src/components/deprecated/index.ts +0 -5
@@ -2,20 +2,19 @@
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 { SelectOptionSchema, type SelectOption } 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
- import { SelectOptionInput } from './SelectOptionsInput';
15
12
  import { translations } from '../../../translations';
16
13
  import { TestLayout, TestPanel } from '../../testing';
17
14
  import { Form } from '../Form';
18
15
 
16
+ import { SelectOptionInput } from './SelectOptionsInput';
17
+
19
18
  const schema = Schema.Struct({
20
19
  options: Schema.Array(SelectOptionSchema).pipe(Schema.mutable),
21
20
  }).pipe(Schema.mutable);
@@ -44,18 +43,19 @@ const DefaultStory = () => {
44
43
  );
45
44
  };
46
45
 
47
- const meta: Meta = {
46
+ const meta = {
48
47
  title: 'ui/react-ui-form/SelectOptionsInput',
49
- component: SelectOptionInput,
48
+ component: SelectOptionInput as any,
50
49
  render: DefaultStory,
51
- decorators: [withTheme, withLayout({ fullscreen: true })],
50
+ decorators: [withTheme],
52
51
  parameters: {
52
+ layout: 'fullscreen',
53
53
  translations,
54
54
  },
55
- };
55
+ } satisfies Meta<typeof DefaultStory>;
56
56
 
57
57
  export default meta;
58
58
 
59
- type Story = StoryObj;
59
+ type Story = StoryObj<typeof meta>;
60
60
 
61
61
  export const Default: Story = {};
@@ -2,20 +2,19 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { type ChangeEvent, type KeyboardEvent, useCallback, useEffect, useState, useRef } from 'react';
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
- import { type ChromaticPalette, Icon, IconButton, Input, Tag, Toolbar, useTranslation } from '@dxos/react-ui';
9
+ import { type ChromaticPalette, IconButton, Input, Tag, useTranslation } from '@dxos/react-ui';
10
10
  import { List } from '@dxos/react-ui-list';
11
11
  import { HuePicker } from '@dxos/react-ui-pickers';
12
- import { hues, mx } from '@dxos/react-ui-theme';
12
+ import { hues, subtleHover } from '@dxos/react-ui-theme';
13
13
 
14
14
  import { translationKey } from '../../../translations';
15
15
  import { InputHeader, type InputProps } from '../Input';
16
16
 
17
- // TODO(ZaymonFC): Handle disabled.
18
- export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue, onValueChange }: InputProps) => {
17
+ export const SelectOptionInput = ({ type, label, readonly, getStatus, getValue, onValueChange }: InputProps) => {
19
18
  const [selected, setSelectedId] = useState<string | null>(null);
20
19
  const [isNewOption, setIsNewOption] = useState(false);
21
20
  const inputRef = useRef<HTMLInputElement>(null);
@@ -115,7 +114,12 @@ export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue,
115
114
  <InputHeader error={error} label={label} />
116
115
  <div role='none'>
117
116
  {options && (
118
- <List.Root items={options} isItem={(item) => true} onMove={handleMove}>
117
+ <List.Root
118
+ items={options}
119
+ isItem={(item) => true}
120
+ onMove={readonly ? undefined : handleMove}
121
+ readonly={!!readonly}
122
+ >
119
123
  {({ items }) => (
120
124
  <div role='list' className='w-full overflow-auto'>
121
125
  {items?.map((item) => (
@@ -123,26 +127,30 @@ export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue,
123
127
  <List.Item
124
128
  role='button'
125
129
  item={item}
126
- classNames={['p-1 plb-2', 'flex flex-col', 'cursor-pointer', 'hover:bg-hoverOverlay']}
130
+ classNames={[subtleHover, 'flex flex-col cursor-pointer rounded-sm']}
127
131
  aria-expanded={selected === item.id}
128
132
  >
129
133
  <div className='flex items-center'>
130
- <List.ItemDragHandle />
134
+ <List.ItemDragHandle disabled={!!readonly} />
131
135
  <List.ItemTitle onClick={() => handleClick(item.id)} classNames='flex-1'>
132
136
  {/* TODO(ZaymonFC): Move spacer into Tag component. */}
133
137
  <Tag palette={item.color as ChromaticPalette}>{item.title || '\u200b'}</Tag>
134
138
  </List.ItemTitle>
135
- <Icon
139
+ <IconButton
140
+ iconOnly
141
+ variant='ghost'
142
+ label={t(selected === item.id ? 'collapse label' : 'expand label', { ns: 'os' })}
136
143
  icon={selected === item.id ? 'ph--caret-down--regular' : 'ph--caret-right--regular'}
137
144
  onClick={() => handleClick(item.id)}
138
145
  />
139
146
  </div>
140
147
  {selected === item.id && (
141
- <div className='mis-4 mbs-2 flex flex-col gap-1'>
148
+ <div className='pli-cardSpacingInline plb-cardSpacingBlock flex flex-col gap-1 density-fine'>
142
149
  {/* 16px to match drag handle width. */}
143
150
  <Input.Label classNames='text-xs'>{t('select option label')}</Input.Label>
144
- <div className='flex flex-row items-center gap-1'>
151
+ <div className='grid grid-cols-[1fr_min-content_min-content] gap-1'>
145
152
  <Input.TextInput
153
+ disabled={!!readonly}
146
154
  placeholder={t('select option label placeholder')}
147
155
  ref={selected === item.id ? inputRef : undefined}
148
156
  value={item.title}
@@ -151,14 +159,9 @@ export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue,
151
159
  classNames='flex-1'
152
160
  data-no-submit
153
161
  />
154
- <Toolbar.Root classNames='p-0 m-0 !is-auto'>
155
- <HuePicker
156
- value={item.color}
157
- onChange={handleColorChange(item.id)}
158
- rootVariant='toolbar-button'
159
- />
160
- </Toolbar.Root>
162
+ <HuePicker disabled={!!readonly} value={item.color} onChange={handleColorChange(item.id)} />
161
163
  <IconButton
164
+ disabled={!!readonly}
162
165
  icon='ph--trash--fill'
163
166
  iconOnly
164
167
  label={t('select option delete')}
@@ -170,18 +173,14 @@ export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue,
170
173
  </List.Item>
171
174
  </div>
172
175
  ))}
173
- <div role='listitem'>
174
- <div
175
- role='button'
176
- className={mx('p-1 plb-2', 'flex flex-col', 'cursor-pointer', 'hover:bg-hoverOverlay')}
177
- onClick={handleAdd}
178
- >
179
- <div className='flex items-center gap-1'>
180
- <Icon icon='ph--plus--regular' />
181
- <span className='text-sm'>{t('select option add')}</span>
182
- </div>
183
- </div>
184
- </div>
176
+ <IconButton
177
+ variant='ghost'
178
+ icon='ph--plus--regular'
179
+ label={t('select option add')}
180
+ onClick={handleAdd}
181
+ disabled={!!readonly}
182
+ classNames='is-full'
183
+ />
185
184
  </div>
186
185
  )}
187
186
  </List.Root>
@@ -16,7 +16,7 @@ export const TupleInput = ({
16
16
  binding,
17
17
  type,
18
18
  label,
19
- disabled,
19
+ readonly,
20
20
  getStatus,
21
21
  getValue,
22
22
  onValueChange,
@@ -35,7 +35,7 @@ export const TupleInput = ({
35
35
  <Input.TextInput
36
36
  key={prop}
37
37
  type='number'
38
- disabled={disabled}
38
+ disabled={!!readonly}
39
39
  value={values[prop]}
40
40
  onChange={(event) => onValueChange(type, { ...values, [prop]: safeParseFloat(event.target.value, 0) })}
41
41
  onBlur={onBlur}
@@ -2,12 +2,12 @@
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
+ import { GeoPointInput } from './custom';
8
9
  import { BooleanInput, MarkdownInput, NumberInput, TextInput } from './Defaults';
9
10
  import { type InputComponent } from './Input';
10
- import { GeoPointInput } from './custom';
11
11
 
12
12
  /**
13
13
  * Get property input component.
@@ -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 { useClient } from '@dxos/react-client';
13
+ import { Filter, useQuery } from '@dxos/react-client/echo';
14
+ import { useClientProvider, withClientProvider } from '@dxos/react-client/testing';
15
+ import { Button } from '@dxos/react-ui';
16
+ import { withTheme } from '@dxos/react-ui/testing';
17
+ import { DataType } from '@dxos/schema';
18
+
19
+ import { translations } from '../../translations';
20
+
21
+ import { ObjectPicker } from './ObjectPicker';
22
+
23
+ faker.seed(1);
24
+
25
+ const createPerson = () =>
26
+ Obj.make(DataType.Person, {
27
+ fullName: faker.person.fullName(),
28
+ });
29
+
30
+ const omitId = Schema.omit<any, any, ['id']>('id');
31
+ const personSchema = omitId(DataType.Person);
32
+
33
+ // Mock functions for testing
34
+ const mockHandleSelect = fn();
35
+ const mockHandleCreate = fn();
36
+
37
+ const DefaultStory = () => {
38
+ const client = useClient();
39
+ const { space } = useClientProvider();
40
+ const [isOpen, setIsOpen] = useState(false);
41
+
42
+ // Create sample Person objects
43
+ useEffect(() => {
44
+ if (space) {
45
+ // Create some sample Person objects for testing
46
+ Array.from({ length: 10 }).forEach(() => {
47
+ space.db.add(createPerson());
48
+ });
49
+ }
50
+ }, [space]);
51
+
52
+ // Get all objects in the space (similar to BoardContainer)
53
+ const allObjects = useQuery(space, Filter.everything());
54
+
55
+ // Map objects to options format expected by ObjectPicker
56
+ const options = useMemo(
57
+ () =>
58
+ allObjects.map((obj) => ({
59
+ id: obj.id,
60
+ label: obj.fullName || obj.name || obj.title || obj.id,
61
+ hue: 'neutral' as const,
62
+ })),
63
+ [allObjects],
64
+ );
65
+
66
+ const handleCreateCallback = useCallback(
67
+ (values: any) => {
68
+ console.log('[on create]', values);
69
+ if (!space) return;
70
+ const newPerson = space.db.add(Obj.make(DataType.Person, values));
71
+ mockHandleCreate(values);
72
+ return newPerson;
73
+ },
74
+ [space],
75
+ );
76
+
77
+ return (
78
+ <div className='p-4'>
79
+ <ObjectPicker.Root open={isOpen} onOpenChange={setIsOpen}>
80
+ <ObjectPicker.Trigger asChild>
81
+ <Button variant='primary' data-testid='trigger'>
82
+ Select Person
83
+ </Button>
84
+ </ObjectPicker.Trigger>
85
+ <ObjectPicker.Content
86
+ options={options}
87
+ onSelect={mockHandleSelect}
88
+ createSchema={personSchema}
89
+ createOptionLabel={['create new person label', { ns: 'os' }]}
90
+ createOptionIcon='ph--user-plus--regular'
91
+ createInitialValuePath='fullName'
92
+ onCreate={handleCreateCallback}
93
+ classNames='popover-card-width'
94
+ />
95
+ </ObjectPicker.Root>
96
+ </div>
97
+ );
98
+ };
99
+
100
+ const meta = {
101
+ title: 'ui/react-ui-form/ObjectPicker',
102
+ render: DefaultStory,
103
+ decorators: [
104
+ withTheme,
105
+ withClientProvider({
106
+ types: [DataType.Person],
107
+ createIdentity: true,
108
+ createSpace: true,
109
+ }),
110
+ ],
111
+ parameters: {
112
+ layout: 'centered',
113
+ controls: { disable: true },
114
+ translations,
115
+ },
116
+ } satisfies Meta<typeof DefaultStory>;
117
+
118
+ export default meta;
119
+
120
+ type Story = StoryObj<typeof meta>;
121
+
122
+ export const Default: 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 = await 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 { PopoverCombobox } 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
+ <PopoverCombobox.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
+ <PopoverCombobox.Input
122
+ placeholder={t('ref field combobox input placeholder')}
123
+ value={searchString}
124
+ onValueChange={setSearchString}
125
+ autoFocus
126
+ />
127
+ <PopoverCombobox.List>
128
+ {options.map((option) => (
129
+ <PopoverCombobox.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
+ </PopoverCombobox.Item>
138
+ ))}
139
+ {searchString.length > 0 && createOptionLabel && createOptionIcon && createSchema && onCreate && (
140
+ <PopoverCombobox.Item value='__create__' classNames='flex items-center gap-2'>
141
+ <Icon icon={createOptionIcon} />
142
+ {t(createOptionLabel[0], { ns: createOptionLabel[1].ns, text: searchString })}
143
+ </PopoverCombobox.Item>
144
+ )}
145
+ </PopoverCombobox.List>
146
+ </>
147
+ )}
148
+ <PopoverCombobox.Arrow />
149
+ </PopoverCombobox.Content>
150
+ );
151
+ },
152
+ );
153
+
154
+ ObjectPickerContent.displayName = 'ObjectPickerContent';
155
+
156
+ export const ObjectPicker = {
157
+ Root: PopoverCombobox.Root,
158
+ Trigger: PopoverCombobox.Trigger,
159
+ VirtualTrigger: PopoverCombobox.VirtualTrigger,
160
+ Content: ObjectPickerContent,
161
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ export * from './ObjectPicker';