@dxos/react-ui-form 0.8.1 → 0.8.2-main.10c050d

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 (112) hide show
  1. package/dist/lib/browser/index.mjs +1818 -1309
  2. package/dist/lib/browser/index.mjs.map +4 -4
  3. package/dist/lib/browser/meta.json +1 -1
  4. package/dist/lib/node/index.cjs +1774 -1273
  5. package/dist/lib/node/index.cjs.map +4 -4
  6. package/dist/lib/node/meta.json +1 -1
  7. package/dist/lib/node-esm/index.mjs +1818 -1309
  8. package/dist/lib/node-esm/index.mjs.map +4 -4
  9. package/dist/lib/node-esm/meta.json +1 -1
  10. package/dist/types/src/components/{ControlSection.d.ts → ControlSection/ControlSection.d.ts} +9 -6
  11. package/dist/types/src/components/ControlSection/ControlSection.d.ts.map +1 -0
  12. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts +8 -0
  13. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +1 -0
  14. package/dist/types/src/components/ControlSection/index.d.ts +2 -0
  15. package/dist/types/src/components/ControlSection/index.d.ts.map +1 -0
  16. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  17. package/dist/types/src/components/FieldEditor/FieldEditor.test.d.ts +2 -0
  18. package/dist/types/src/components/FieldEditor/FieldEditor.test.d.ts.map +1 -0
  19. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -1
  20. package/dist/types/src/components/Form/Defaults.d.ts +4 -4
  21. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
  22. package/dist/types/src/components/Form/Form.d.ts +6 -4
  23. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  24. package/dist/types/src/components/Form/Form.stories.d.ts +38 -36
  25. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  26. package/dist/types/src/components/Form/FormActions.d.ts +1 -1
  27. package/dist/types/src/components/Form/FormActions.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/FormContent.d.ts +16 -12
  29. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
  30. package/dist/types/src/components/Form/FormContext.d.ts +2 -2
  31. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -1
  32. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  33. package/dist/types/src/components/Form/RefField.d.ts +12 -0
  34. package/dist/types/src/components/Form/RefField.d.ts.map +1 -0
  35. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -1
  36. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +1 -1
  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.map +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/Form/index.d.ts.map +1 -1
  42. package/dist/types/src/components/ViewEditor/Test.stories.d.ts.map +1 -1
  43. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +2 -2
  44. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  45. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +5 -3
  46. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  47. package/dist/types/src/components/deprecated/Form.d.ts +2 -2
  48. package/dist/types/src/components/deprecated/Form.d.ts.map +1 -1
  49. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +1 -1
  50. package/dist/types/src/components/deprecated/FormInput.d.ts.map +1 -1
  51. package/dist/types/src/components/index.d.ts.map +1 -1
  52. package/dist/types/src/components/testing.d.ts.map +1 -1
  53. package/dist/types/src/hooks/index.d.ts +2 -0
  54. package/dist/types/src/hooks/index.d.ts.map +1 -1
  55. package/dist/types/src/hooks/useForm.d.ts +2 -2
  56. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  57. package/dist/types/src/hooks/useQueryRefOptions.d.ts +23 -0
  58. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +1 -0
  59. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts +8 -0
  60. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +1 -0
  61. package/dist/types/src/translations.d.ts +5 -0
  62. package/dist/types/src/translations.d.ts.map +1 -1
  63. package/dist/types/src/util/field.d.ts.map +1 -1
  64. package/dist/types/src/util/formatting.d.ts.map +1 -1
  65. package/dist/types/src/util/index.d.ts +2 -0
  66. package/dist/types/src/util/index.d.ts.map +1 -1
  67. package/dist/types/src/util/refs.d.ts +9 -0
  68. package/dist/types/src/util/refs.d.ts.map +1 -0
  69. package/dist/types/src/util/schema.d.ts +3 -0
  70. package/dist/types/src/util/schema.d.ts.map +1 -0
  71. package/dist/types/src/vitest-setup.d.ts +2 -0
  72. package/dist/types/src/vitest-setup.d.ts.map +1 -0
  73. package/dist/types/tsconfig.tsbuildinfo +1 -1
  74. package/package.json +28 -25
  75. package/src/components/ControlSection/ControlSection.stories.tsx +29 -0
  76. package/src/components/{ControlSection.tsx → ControlSection/ControlSection.tsx} +40 -16
  77. package/src/components/ControlSection/index.ts +5 -0
  78. package/src/components/FieldEditor/FieldEditor.stories.tsx +1 -1
  79. package/src/components/FieldEditor/FieldEditor.test.tsx +49 -0
  80. package/src/components/FieldEditor/FieldEditor.tsx +3 -2
  81. package/src/components/Form/ArrayField.tsx +63 -50
  82. package/src/components/Form/Defaults.tsx +77 -48
  83. package/src/components/Form/Form.stories.tsx +134 -109
  84. package/src/components/Form/Form.tsx +8 -5
  85. package/src/components/Form/FormActions.tsx +15 -18
  86. package/src/components/Form/FormContent.tsx +116 -46
  87. package/src/components/Form/FormContext.tsx +24 -10
  88. package/src/components/Form/Input.tsx +4 -10
  89. package/src/components/Form/RefField.tsx +217 -0
  90. package/src/components/Form/custom/GeoPointInput.tsx +2 -2
  91. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +6 -5
  92. package/src/components/Form/custom/SelectOptionsInput.tsx +2 -2
  93. package/src/components/Form/factory.tsx +1 -1
  94. package/src/components/Form/index.ts +0 -1
  95. package/src/components/ViewEditor/ViewEditor.stories.tsx +19 -9
  96. package/src/components/ViewEditor/ViewEditor.tsx +65 -35
  97. package/src/components/deprecated/Form.stories.tsx +6 -6
  98. package/src/components/deprecated/Form.tsx +6 -6
  99. package/src/components/deprecated/FormInput.tsx +1 -1
  100. package/src/components/index.ts +1 -0
  101. package/src/components/testing.tsx +5 -9
  102. package/src/hooks/index.ts +3 -1
  103. package/src/hooks/useForm.ts +4 -3
  104. package/src/hooks/useQueryRefOptions.ts +53 -0
  105. package/src/hooks/useRefQueryLookupHandler.ts +39 -0
  106. package/src/translations.ts +9 -2
  107. package/src/util/formatting.ts +15 -3
  108. package/src/util/index.ts +3 -1
  109. package/src/util/refs.ts +38 -0
  110. package/src/util/schema.ts +14 -0
  111. package/src/vitest-setup.ts +11 -0
  112. package/dist/types/src/components/ControlSection.d.ts.map +0 -1
@@ -1,13 +1,13 @@
1
1
  //
2
- // Copyright 2024 DXOS.org
2
+ // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import { pipe } from 'effect';
5
+ import { Schema, SchemaAST, pipe } from 'effect';
6
6
  import { capitalize } from 'effect/String';
7
7
  import React, { useMemo } from 'react';
8
8
 
9
- import { AST, S } from '@dxos/echo-schema';
10
9
  import { createJsonPath, findNode, getDiscriminatedType, isDiscriminatedUnion } from '@dxos/effect';
10
+ import { type ThemedClassName } from '@dxos/react-ui';
11
11
  import { mx } from '@dxos/react-ui-theme';
12
12
  import { getSchemaProperties, type SchemaProperty } from '@dxos/schema';
13
13
  import { isNotFalsy } from '@dxos/util';
@@ -17,17 +17,10 @@ import { SelectInput } from './Defaults';
17
17
  import { type ComponentLookup } from './Form';
18
18
  import { useInputProps, useFormValues } from './FormContext';
19
19
  import { type InputComponent } from './Input';
20
+ import { RefField } from './RefField';
20
21
  import { getInputComponent } from './factory';
21
-
22
- export type FormContentProps = {
23
- schema: S.Schema.All;
24
- path?: (string | number)[];
25
- filter?: (props: SchemaProperty<any>[]) => SchemaProperty<any>[];
26
- sort?: string[];
27
- readonly?: boolean;
28
- lookupComponent?: ComponentLookup;
29
- Custom?: Partial<Record<string, InputComponent>>;
30
- };
22
+ import { type QueryRefOptions } from '../../hooks';
23
+ import { getRefProps } from '../../util';
31
24
 
32
25
  export type FormFieldProps = {
33
26
  property: SchemaProperty<any>;
@@ -35,11 +28,20 @@ export type FormFieldProps = {
35
28
  readonly?: boolean;
36
29
  /** Used to indicate if input should be presented inline (e.g. for array items). */
37
30
  inline?: boolean;
31
+ onQueryRefOptions?: QueryRefOptions;
38
32
  lookupComponent?: ComponentLookup;
39
33
  Custom?: Partial<Record<string, InputComponent>>;
40
34
  };
41
35
 
42
- export const FormField = ({ property, path, readonly, inline, lookupComponent, Custom }: FormFieldProps) => {
36
+ export const FormField = ({
37
+ property,
38
+ path,
39
+ readonly,
40
+ inline,
41
+ onQueryRefOptions,
42
+ lookupComponent,
43
+ Custom,
44
+ }: FormFieldProps) => {
43
45
  const inputProps = useInputProps(path);
44
46
  const { ast, name, type, format, title, description, options, examples, array } = property;
45
47
 
@@ -49,9 +51,13 @@ export const FormField = ({ property, path, readonly, inline, lookupComponent, C
49
51
  [examples, description],
50
52
  );
51
53
 
54
+ //
55
+ // Registry and Custom.
56
+ //
57
+
52
58
  const FoundComponent = lookupComponent?.({
53
59
  prop: name,
54
- schema: S.make(ast),
60
+ schema: Schema.make(ast),
55
61
  inputProps: {
56
62
  type,
57
63
  format,
@@ -63,7 +69,7 @@ export const FormField = ({ property, path, readonly, inline, lookupComponent, C
63
69
  });
64
70
 
65
71
  if (FoundComponent) {
66
- return <div role='none'>{FoundComponent}</div>;
72
+ return FoundComponent;
67
73
  }
68
74
 
69
75
  const jsonPath = createJsonPath(path ?? []);
@@ -82,56 +88,97 @@ export const FormField = ({ property, path, readonly, inline, lookupComponent, C
82
88
  );
83
89
  }
84
90
 
91
+ //
92
+ // Refs.
93
+ //
94
+
95
+ const refProps = getRefProps(property);
96
+ if (refProps) {
97
+ return (
98
+ <RefField
99
+ ast={refProps.ast}
100
+ array={refProps.isArray}
101
+ type={type}
102
+ format={format}
103
+ label={label}
104
+ placeholder={placeholder}
105
+ disabled={readonly}
106
+ inputOnly={inline}
107
+ onQueryRefOptions={onQueryRefOptions}
108
+ {...inputProps}
109
+ />
110
+ );
111
+ }
112
+
113
+ //
114
+ // Arrays.
115
+ //
116
+
85
117
  if (array) {
86
118
  return <ArrayField property={property} path={path} inputProps={inputProps} readonly={readonly} Custom={Custom} />;
87
119
  }
88
120
 
121
+ //
122
+ // Standard Inputs.
123
+ //
124
+
89
125
  const InputComponent = getInputComponent(type, format);
90
126
  if (InputComponent) {
91
127
  return (
92
- <div role='none'>
93
- <InputComponent
94
- type={type}
95
- format={format}
96
- label={label}
97
- inputOnly={inline}
98
- placeholder={placeholder}
99
- disabled={readonly}
100
- {...inputProps}
101
- />
102
- </div>
128
+ <InputComponent
129
+ type={type}
130
+ format={format}
131
+ label={label}
132
+ inputOnly={inline}
133
+ placeholder={placeholder}
134
+ disabled={readonly}
135
+ {...inputProps}
136
+ />
103
137
  );
104
138
  }
105
139
 
140
+ //
141
+ // Select.
142
+ //
143
+
106
144
  if (options) {
107
145
  return (
108
- <div role='none'>
109
- <SelectInput
110
- type={type}
111
- format={format}
112
- disabled={readonly}
113
- inputOnly={inline}
114
- label={label}
115
- options={options.map((option) => ({ value: option, label: String(option) }))}
116
- placeholder={placeholder}
117
- {...inputProps}
118
- />
119
- </div>
146
+ <SelectInput
147
+ type={type}
148
+ format={format}
149
+ disabled={readonly}
150
+ inputOnly={inline}
151
+ label={label}
152
+ options={options.map((option) => ({ value: option, label: String(option) }))}
153
+ placeholder={placeholder}
154
+ {...inputProps}
155
+ />
120
156
  );
121
157
  }
122
158
 
159
+ //
160
+ // Nested Objects.
161
+ //
162
+
123
163
  if (type === 'object') {
124
164
  const baseNode = findNode(ast, isDiscriminatedUnion);
125
165
  const typeLiteral = baseNode
126
166
  ? getDiscriminatedType(baseNode, inputProps.getValue() as any)
127
- : findNode(ast, AST.isTypeLiteral);
167
+ : findNode(ast, SchemaAST.isTypeLiteral);
128
168
 
129
169
  if (typeLiteral) {
130
170
  return (
131
- <div role='none'>
132
- {!inline && <div>{label}</div>}
133
- <FormFields schema={S.make(typeLiteral)} path={path} readonly={readonly} Custom={Custom} />
134
- </div>
171
+ <>
172
+ {!inline && <h3 className='text-lg mbs-2 mbe-1 first:mbs-0'>{label}</h3>}
173
+ <FormFields
174
+ schema={Schema.make(typeLiteral)}
175
+ path={path}
176
+ readonly={readonly}
177
+ onQueryRefOptions={onQueryRefOptions}
178
+ Custom={Custom}
179
+ lookupComponent={lookupComponent}
180
+ />
181
+ </>
135
182
  );
136
183
  }
137
184
  }
@@ -139,7 +186,28 @@ export const FormField = ({ property, path, readonly, inline, lookupComponent, C
139
186
  return null;
140
187
  };
141
188
 
142
- export const FormFields = ({ schema, path, filter, sort, readonly, lookupComponent, Custom }: FormContentProps) => {
189
+ export type FormFieldsProps = ThemedClassName<{
190
+ Custom?: Partial<Record<string, InputComponent>>;
191
+ schema: Schema.Schema.All;
192
+ path?: (string | number)[];
193
+ filter?: (props: SchemaProperty<any>[]) => SchemaProperty<any>[];
194
+ sort?: string[];
195
+ readonly?: boolean;
196
+ lookupComponent?: ComponentLookup;
197
+ onQueryRefOptions?: QueryRefOptions;
198
+ }>;
199
+
200
+ export const FormFields = ({
201
+ classNames,
202
+ Custom,
203
+ schema,
204
+ path,
205
+ filter,
206
+ sort,
207
+ readonly,
208
+ lookupComponent,
209
+ onQueryRefOptions,
210
+ }: FormFieldsProps) => {
143
211
  const values = useFormValues(path);
144
212
 
145
213
  const properties = useMemo(() => {
@@ -150,7 +218,8 @@ export const FormFields = ({ schema, path, filter, sort, readonly, lookupCompone
150
218
  }, [schema, values, filter, sort]);
151
219
 
152
220
  return (
153
- <div role='form' className={mx('flex flex-col w-full gap-2')}>
221
+ // TODO(burdon): Consistent padding (with composite forms).
222
+ <div role='form' className={mx('p-2 is-full', classNames)}>
154
223
  {properties
155
224
  .map((property) => {
156
225
  return (
@@ -159,6 +228,7 @@ export const FormFields = ({ schema, path, filter, sort, readonly, lookupCompone
159
228
  property={property}
160
229
  path={[...(path ?? []), property.name]}
161
230
  readonly={readonly}
231
+ onQueryRefOptions={onQueryRefOptions}
162
232
  lookupComponent={lookupComponent}
163
233
  Custom={Custom}
164
234
  />
@@ -2,7 +2,16 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { createContext, useContext, useEffect, useMemo, type FocusEvent, type PropsWithChildren } from 'react';
5
+ import React, {
6
+ createContext,
7
+ type RefObject,
8
+ useContext,
9
+ useEffect,
10
+ useMemo,
11
+ type FocusEvent,
12
+ type PropsWithChildren,
13
+ useCallback,
14
+ } from 'react';
6
15
 
7
16
  import { raise } from '@dxos/debug';
8
17
  import { type BaseObject, getValue } from '@dxos/echo-schema';
@@ -53,22 +62,20 @@ export const FormProvider = ({
53
62
  ...formOptions
54
63
  }: PropsWithChildren<
55
64
  FormOptions<any> & {
56
- formRef?: React.RefObject<HTMLDivElement>;
65
+ formRef?: RefObject<HTMLDivElement>;
57
66
  autoSave?: boolean;
58
67
  }
59
68
  >) => {
60
69
  const form = useForm(formOptions);
61
70
 
62
- useEffect(() => {
63
- if (!formRef?.current) {
64
- return;
65
- }
66
-
67
- const handleKeyDown = (event: KeyboardEvent) => {
71
+ const handleKeyDown = useCallback(
72
+ (event: KeyboardEvent) => {
68
73
  const keyIsEnter = event.key === 'Enter';
69
74
  const modifierUsed = event.ctrlKey || event.altKey || event.metaKey || event.shiftKey;
70
75
  const inputIsTextarea = (event.target as HTMLElement).tagName.toLowerCase() === 'textarea';
71
- const inputOptOut = (event.target as HTMLElement).hasAttribute('data-no-submit');
76
+ const inputOptOut =
77
+ (event.target as HTMLElement).hasAttribute('data-no-submit') ||
78
+ (event.target as HTMLElement).closest('[data-no-submit]') !== null;
72
79
 
73
80
  // Regular inputs: Submit on Enter (no modifiers).
74
81
  const shouldSubmitRegularInput = !inputIsTextarea && keyIsEnter && !modifierUsed;
@@ -84,7 +91,14 @@ export const FormProvider = ({
84
91
  (event.target as HTMLElement).blur();
85
92
  }
86
93
  }
87
- };
94
+ },
95
+ [form, autoSave],
96
+ );
97
+
98
+ useEffect(() => {
99
+ if (!formRef?.current) {
100
+ return;
101
+ }
88
102
 
89
103
  const formElement = formRef.current;
90
104
 
@@ -34,18 +34,12 @@ export type InputHeaderProps = PropsWithChildren<{
34
34
 
35
35
  export const InputHeader = ({ children, error }: InputHeaderProps) => {
36
36
  return (
37
- <div role='none' className='flex justify-between items-center mb-1'>
37
+ <div role='none' className='flex justify-between items-center mbs-2 mbe-1 first:mbs-0'>
38
38
  {children}
39
39
  {error && (
40
- <Tooltip.Root>
41
- <Tooltip.Trigger asChild>
42
- <Icon icon='ph--warning--regular' size={4} classNames={errorText} />
43
- </Tooltip.Trigger>
44
- <Tooltip.Content side='bottom'>
45
- <Tooltip.Arrow />
46
- {error}
47
- </Tooltip.Content>
48
- </Tooltip.Root>
40
+ <Tooltip.Trigger asChild content={error} side='bottom'>
41
+ <Icon icon='ph--warning--regular' size={4} classNames={errorText} />
42
+ </Tooltip.Trigger>
49
43
  )}
50
44
  </div>
51
45
  );
@@ -0,0 +1,217 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type SchemaAST } from 'effect';
6
+ import React, { useCallback, useMemo, type FocusEvent } from 'react';
7
+
8
+ import {
9
+ Expando,
10
+ getTypeAnnotation,
11
+ Ref,
12
+ ReferenceAnnotationId,
13
+ type ReferenceAnnotationValue,
14
+ } from '@dxos/echo-schema';
15
+ import { findAnnotation } from '@dxos/effect';
16
+ import { DXN } from '@dxos/keys';
17
+ import { DxRefTag } from '@dxos/lit-ui/react';
18
+ import { Input, useTranslation } from '@dxos/react-ui';
19
+ import { TagPicker, type TagPickerMode, type TagPickerItemData } from '@dxos/react-ui-tag-picker';
20
+ import { descriptionText, mx } from '@dxos/react-ui-theme';
21
+ import { isNonNullable } from '@dxos/util';
22
+
23
+ import { TextInput } from './Defaults';
24
+ import { InputHeader, type InputProps } from './Input';
25
+ import { type QueryRefOptions, useQueryRefOptions } from '../../hooks';
26
+ import { translationKey } from '../../translations';
27
+
28
+ type RefFieldProps = InputProps & {
29
+ ast?: SchemaAST.AST;
30
+ array?: boolean;
31
+ onQueryRefOptions?: QueryRefOptions;
32
+ };
33
+
34
+ export const RefField = ({
35
+ type,
36
+ label,
37
+ disabled,
38
+ placeholder,
39
+ inputOnly,
40
+ array,
41
+ ast,
42
+ getValue,
43
+ onBlur,
44
+ onQueryRefOptions,
45
+ onValueChange,
46
+ ...restInputProps
47
+ }: RefFieldProps) => {
48
+ const { t } = useTranslation(translationKey);
49
+ const refTypeInfo = useMemo(
50
+ () => (ast ? findAnnotation<ReferenceAnnotationValue>(ast, ReferenceAnnotationId) : undefined),
51
+ [ast],
52
+ );
53
+ const { options: availableOptions, loading: _loading } = useQueryRefOptions({ refTypeInfo, onQueryRefOptions });
54
+
55
+ if ((refTypeInfo && refTypeInfo?.typename === getTypeAnnotation(Expando)?.typename) || !onQueryRefOptions) {
56
+ // If ref type is expando, fall back to taking a DXN in string format.
57
+ return (
58
+ <RefFieldFallback
59
+ {...{ type, label, placeholder, disabled, inputOnly, getValue, onBlur, onValueChange, ...restInputProps }}
60
+ />
61
+ );
62
+ }
63
+
64
+ const handleGetValue = useCallback((): TagPickerItemData[] => {
65
+ const formValue = getValue();
66
+
67
+ const unknownToRefOption = (val: unknown) => {
68
+ if (Ref.isRef(val)) {
69
+ const dxnString = val.dxn.toString();
70
+ const matchingOption = availableOptions.find((option) => option.id === dxnString);
71
+ if (matchingOption) {
72
+ return matchingOption;
73
+ }
74
+ }
75
+ return undefined;
76
+ };
77
+
78
+ if (array && Array.isArray(formValue)) {
79
+ return formValue.map(unknownToRefOption).filter(isNonNullable) ?? [];
80
+ }
81
+
82
+ const option = unknownToRefOption(formValue);
83
+ if (option) {
84
+ return [option];
85
+ }
86
+
87
+ return [];
88
+ }, [getValue, availableOptions, array]);
89
+
90
+ const handleSearch = useCallback(
91
+ (text: string, ids: string[]): TagPickerItemData[] =>
92
+ availableOptions
93
+ .filter((option) => !ids.includes(option.id))
94
+ .filter((option) => option.label.toLowerCase().includes(text.toLowerCase()))
95
+ .map((option) => ({ id: option.id, label: option.label, hue: option.hue as any })),
96
+ [availableOptions],
97
+ );
98
+
99
+ const handleUpdate = useCallback(
100
+ (ids: string[]) => {
101
+ if (ids.length === 0) {
102
+ onValueChange('object', undefined);
103
+ return;
104
+ }
105
+
106
+ const refs = ids
107
+ .map((id) => {
108
+ const item = availableOptions.find((option) => option.id === id);
109
+ if (item) {
110
+ const dxn = DXN.parse(item.id);
111
+ return Ref.fromDXN(dxn);
112
+ }
113
+ return null;
114
+ })
115
+ .filter(isNonNullable);
116
+
117
+ if (array) {
118
+ onValueChange('object', refs);
119
+ } else {
120
+ onValueChange('object', refs[0]);
121
+ }
122
+ },
123
+ [availableOptions, array, onValueChange],
124
+ );
125
+
126
+ const tagPickerMode: TagPickerMode = useMemo(() => (array ? 'multi-select' : 'single-select'), [array]);
127
+
128
+ if (!refTypeInfo) {
129
+ return null;
130
+ }
131
+
132
+ const { status, error } = restInputProps.getStatus();
133
+
134
+ const items = handleGetValue();
135
+
136
+ // NOTE(thure): I left both predicates in-place in case we decide to add variants which do render readonly but empty values.
137
+ return disabled && items.length < 1 ? null : (
138
+ <Input.Root validationValence={status}>
139
+ {!inputOnly && (
140
+ <InputHeader error={error}>
141
+ <Input.Label>{label}</Input.Label>
142
+ </InputHeader>
143
+ )}
144
+ <div data-no-submit>
145
+ {disabled ? (
146
+ items.length < 1 ? (
147
+ <p className={mx(descriptionText, 'mbe-2')}>{t('empty readonly ref field label')}</p>
148
+ ) : (
149
+ items.map((item) => (
150
+ <DxRefTag key={item.id} refid={item.id} rootclassname='mie-1' style={{ marginInlineEnd: '.25rem' }}>
151
+ {item.label}
152
+ </DxRefTag>
153
+ ))
154
+ )
155
+ ) : (
156
+ <TagPicker
157
+ readonly={disabled}
158
+ items={items}
159
+ mode={tagPickerMode}
160
+ onBlur={(event) => onBlur(event as unknown as FocusEvent<HTMLElement>)}
161
+ onUpdate={handleUpdate}
162
+ onSearch={handleSearch}
163
+ classNames='rounded-sm bg-inputSurface p-1.5'
164
+ />
165
+ )}
166
+ </div>
167
+ {inputOnly && <Input.DescriptionAndValidation>{error}</Input.DescriptionAndValidation>}
168
+ </Input.Root>
169
+ );
170
+ };
171
+
172
+ const RefFieldFallback = ({
173
+ type,
174
+ label,
175
+ disabled,
176
+ placeholder,
177
+ inputOnly,
178
+ getValue,
179
+ onValueChange,
180
+ ...restInputProps
181
+ }: InputProps) => {
182
+ const handleOnValueChange = (_type: any, dxnString: string) => {
183
+ const dxn = DXN.tryParse(dxnString);
184
+ if (dxn) {
185
+ onValueChange?.('object', Ref.fromDXN(dxn));
186
+ } else if (dxnString === '') {
187
+ onValueChange?.('object', undefined);
188
+ } else {
189
+ onValueChange?.('string', dxnString);
190
+ }
191
+ };
192
+
193
+ const handleGetValue = () => {
194
+ const formValue = getValue();
195
+ if (typeof formValue === 'string') {
196
+ return formValue;
197
+ }
198
+ if (Ref.isRef(formValue)) {
199
+ return formValue.dxn.toString();
200
+ }
201
+
202
+ return undefined;
203
+ };
204
+
205
+ return (
206
+ <TextInput
207
+ type={type}
208
+ label={label}
209
+ disabled={disabled}
210
+ placeholder={placeholder}
211
+ inputOnly={inputOnly}
212
+ getValue={handleGetValue as <V>() => V | undefined}
213
+ onValueChange={handleOnValueChange}
214
+ {...restInputProps}
215
+ />
216
+ );
217
+ };
@@ -2,7 +2,7 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import React, { useCallback, useState, useEffect, useMemo } from 'react';
5
+ import React, { useCallback, useState, useEffect, useMemo, type ChangeEvent } from 'react';
6
6
 
7
7
  import { type GeoPoint, GeoLocation } from '@dxos/echo-schema';
8
8
  import { Input, useTranslation } from '@dxos/react-ui';
@@ -28,7 +28,7 @@ export const GeoPointInput = ({ type, label, disabled, getStatus, getValue, onVa
28
28
 
29
29
  const handleCoordinateChange = useCallback(
30
30
  (coordinateType: keyof Pick<GeoLocation, 'longitude' | 'latitude'>, setText: (text: string) => void) =>
31
- (event: React.ChangeEvent<HTMLInputElement>) => {
31
+ (event: ChangeEvent<HTMLInputElement>) => {
32
32
  const inputText = event.target.value;
33
33
  setText(inputText);
34
34
  if (inputText !== '' && inputText !== '-') {
@@ -5,9 +5,10 @@
5
5
  import '@dxos-theme';
6
6
 
7
7
  import { type Meta, type StoryObj } from '@storybook/react';
8
+ import { Schema } from 'effect';
8
9
  import React, { useState } from 'react';
9
10
 
10
- import { S, SelectOptionSchema, type SelectOption } from '@dxos/echo-schema';
11
+ import { SelectOptionSchema, type SelectOption } from '@dxos/echo-schema';
11
12
  import { withLayout, withTheme } from '@dxos/storybook-utils';
12
13
 
13
14
  import { SelectOptionInput } from './SelectOptionsInput';
@@ -15,9 +16,9 @@ import translations from '../../../translations';
15
16
  import { TestLayout, TestPanel } from '../../testing';
16
17
  import { Form } from '../Form';
17
18
 
18
- const schema = S.Struct({
19
- options: S.Array(SelectOptionSchema).pipe(S.mutable),
20
- }).pipe(S.mutable);
19
+ const schema = Schema.Struct({
20
+ options: Schema.Array(SelectOptionSchema).pipe(Schema.mutable),
21
+ }).pipe(Schema.mutable);
21
22
 
22
23
  const DefaultStory = () => {
23
24
  const [values, setValues] = useState<{ options: SelectOption[] }>({
@@ -46,7 +47,7 @@ const DefaultStory = () => {
46
47
  const meta: Meta = {
47
48
  title: 'ui/react-ui-form/SelectOptionsInput',
48
49
  component: SelectOptionInput,
49
- decorators: [withTheme, withLayout({ tooltips: true })],
50
+ decorators: [withTheme, withLayout()],
50
51
  parameters: { translations },
51
52
  };
52
53
 
@@ -2,7 +2,7 @@
2
2
  // Copyright 2025 DXOS.org
3
3
  //
4
4
 
5
- import React, { useCallback, useEffect, useState, type ChangeEvent, useRef } from 'react';
5
+ import React, { type ChangeEvent, type KeyboardEvent, useCallback, useEffect, useState, useRef } from 'react';
6
6
 
7
7
  import { type SelectOption } from '@dxos/echo-schema';
8
8
  import { PublicKey } from '@dxos/keys';
@@ -89,7 +89,7 @@ export const SelectOptionInput = ({ type, label, disabled, getStatus, getValue,
89
89
  [options, type, onValueChange],
90
90
  );
91
91
 
92
- const handleKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
92
+ const handleKeyDown = useCallback((e: KeyboardEvent<HTMLInputElement>) => {
93
93
  if (e.key === 'Enter') {
94
94
  setSelectedId(null);
95
95
  }
@@ -12,7 +12,7 @@ import { GeoPointInput } from './custom';
12
12
  /**
13
13
  * Get property input component.
14
14
  */
15
- export const getInputComponent = (type: SimpleType, format?: FormatEnum): InputComponent | undefined => {
15
+ export const getInputComponent = (type: SimpleType, format?: FormatEnum | string): InputComponent | undefined => {
16
16
  switch (format) {
17
17
  case FormatEnum.GeoPoint:
18
18
  return GeoPointInput;
@@ -3,7 +3,6 @@
3
3
  //
4
4
 
5
5
  export * from './custom';
6
-
7
6
  export * from './Defaults';
8
7
  export * from './Form';
9
8
  export * from './FormContext';