@dxos/react-ui-form 0.8.3 → 0.8.4-main.1f223c7

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 (103) hide show
  1. package/dist/lib/browser/index.mjs +648 -581
  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 +648 -581
  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 +79 -4
  10. package/dist/types/src/components/ControlSection/ControlSection.stories.d.ts.map +1 -1
  11. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts +4 -5
  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 -6
  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 +5 -4
  20. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/Form.stories.d.ts +126 -30
  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 +16 -10
  25. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -1
  26. package/dist/types/src/components/Form/FormContext.d.ts +1 -1
  27. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -1
  28. package/dist/types/src/components/Form/Input.d.ts +4 -1
  29. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  30. package/dist/types/src/components/Form/RefField.d.ts +10 -4
  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 +74 -3
  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.map +1 -1
  40. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +5 -4
  41. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  42. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +78 -7
  43. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  44. package/dist/types/src/components/index.d.ts +0 -1
  45. package/dist/types/src/components/index.d.ts.map +1 -1
  46. package/dist/types/src/components/testing.d.ts +6 -3
  47. package/dist/types/src/components/testing.d.ts.map +1 -1
  48. package/dist/types/src/hooks/useForm.d.ts +1 -1
  49. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  50. package/dist/types/src/index.d.ts +1 -1
  51. package/dist/types/src/index.d.ts.map +1 -1
  52. package/dist/types/src/translations.d.ts +61 -57
  53. package/dist/types/src/translations.d.ts.map +1 -1
  54. package/dist/types/src/util/field.d.ts.map +1 -1
  55. package/dist/types/tsconfig.tsbuildinfo +1 -1
  56. package/package.json +36 -33
  57. package/src/components/ControlSection/ControlSection.stories.tsx +10 -12
  58. package/src/components/ControlSection/ControlSection.tsx +26 -12
  59. package/src/components/FieldEditor/FieldEditor.stories.tsx +21 -20
  60. package/src/components/FieldEditor/FieldEditor.test.tsx +14 -13
  61. package/src/components/FieldEditor/FieldEditor.tsx +17 -22
  62. package/src/components/Form/ArrayField.tsx +9 -9
  63. package/src/components/Form/Defaults.tsx +22 -20
  64. package/src/components/Form/Form.stories.tsx +131 -95
  65. package/src/components/Form/Form.tsx +16 -12
  66. package/src/components/Form/FormActions.tsx +2 -1
  67. package/src/components/Form/FormContent.tsx +96 -34
  68. package/src/components/Form/FormContext.tsx +5 -5
  69. package/src/components/Form/Input.tsx +4 -1
  70. package/src/components/Form/RefField.tsx +126 -40
  71. package/src/components/Form/custom/GeoPointInput.tsx +29 -21
  72. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +9 -8
  73. package/src/components/Form/custom/SelectOptionsInput.tsx +29 -30
  74. package/src/components/Form/custom/TupleInput.tsx +2 -2
  75. package/src/components/Form/factory.tsx +1 -1
  76. package/src/components/ViewEditor/ViewEditor.stories.tsx +70 -32
  77. package/src/components/ViewEditor/ViewEditor.test.tsx +38 -39
  78. package/src/components/ViewEditor/ViewEditor.tsx +159 -155
  79. package/src/components/index.ts +0 -2
  80. package/src/components/testing.tsx +15 -9
  81. package/src/hooks/useForm.ts +5 -3
  82. package/src/hooks/useRefQueryLookupHandler.ts +1 -1
  83. package/src/index.ts +1 -1
  84. package/src/translations.ts +15 -7
  85. package/src/util/field.ts +8 -18
  86. package/dist/lib/node/index.cjs +0 -2416
  87. package/dist/lib/node/index.cjs.map +0 -7
  88. package/dist/lib/node/meta.json +0 -1
  89. package/dist/types/src/components/ViewEditor/Test.stories.d.ts +0 -10
  90. package/dist/types/src/components/ViewEditor/Test.stories.d.ts.map +0 -1
  91. package/dist/types/src/components/deprecated/Form.d.ts +0 -15
  92. package/dist/types/src/components/deprecated/Form.d.ts.map +0 -1
  93. package/dist/types/src/components/deprecated/Form.stories.d.ts +0 -13
  94. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +0 -1
  95. package/dist/types/src/components/deprecated/FormInput.d.ts +0 -16
  96. package/dist/types/src/components/deprecated/FormInput.d.ts.map +0 -1
  97. package/dist/types/src/components/deprecated/index.d.ts +0 -2
  98. package/dist/types/src/components/deprecated/index.d.ts.map +0 -1
  99. package/src/components/ViewEditor/Test.stories.tsx +0 -45
  100. package/src/components/deprecated/Form.stories.tsx +0 -62
  101. package/src/components/deprecated/Form.tsx +0 -119
  102. package/src/components/deprecated/FormInput.tsx +0 -71
  103. package/src/components/deprecated/index.ts +0 -5
@@ -9,36 +9,43 @@ import React, { forwardRef, useMemo } from 'react';
9
9
  import { createJsonPath, findNode, getDiscriminatedType, isDiscriminatedUnion } from '@dxos/effect';
10
10
  import { type ThemedClassName } from '@dxos/react-ui';
11
11
  import { mx } from '@dxos/react-ui-theme';
12
- import { getSchemaProperties, type SchemaProperty } from '@dxos/schema';
12
+ import { type ProjectionModel, type SchemaProperty, getSchemaProperties } from '@dxos/schema';
13
13
  import { isNotFalsy } from '@dxos/util';
14
14
 
15
+ import { type QueryRefOptions } from '../../hooks';
16
+ import { getRefProps } from '../../util';
17
+
15
18
  import { ArrayField } from './ArrayField';
16
19
  import { SelectInput } from './Defaults';
20
+ import { getInputComponent } from './factory';
17
21
  import { type ComponentLookup } from './Form';
18
- import { useInputProps, useFormValues } from './FormContext';
22
+ import { useFormValues, useInputProps } from './FormContext';
19
23
  import { type InputComponent } from './Input';
20
- import { RefField } from './RefField';
21
- import { getInputComponent } from './factory';
22
- import { type QueryRefOptions } from '../../hooks';
23
- import { getRefProps } from '../../util';
24
+ import { RefField, type RefFieldProps } from './RefField';
24
25
 
25
26
  export type FormFieldProps = {
26
27
  property: SchemaProperty<any>;
27
28
  path?: (string | number)[];
28
- readonly?: boolean;
29
29
  /** Used to indicate if input should be presented inline (e.g. for array items). */
30
30
  inline?: boolean;
31
- onQueryRefOptions?: QueryRefOptions;
31
+ projection?: ProjectionModel;
32
32
  lookupComponent?: ComponentLookup;
33
33
  Custom?: Partial<Record<string, InputComponent>>;
34
- };
34
+ } & Pick<
35
+ RefFieldProps,
36
+ 'readonly' | 'onQueryRefOptions' | 'createOptionLabel' | 'createOptionIcon' | 'onCreateFromQuery'
37
+ >;
35
38
 
36
39
  export const FormField = ({
37
40
  property,
38
41
  path,
39
42
  readonly,
40
43
  inline,
44
+ projection,
41
45
  onQueryRefOptions,
46
+ createOptionLabel,
47
+ createOptionIcon,
48
+ onCreateFromQuery,
42
49
  lookupComponent,
43
50
  Custom,
44
51
  }: FormFieldProps) => {
@@ -62,7 +69,7 @@ export const FormField = ({
62
69
  type,
63
70
  format,
64
71
  label,
65
- disabled: readonly,
72
+ readonly,
66
73
  placeholder,
67
74
  ...inputProps,
68
75
  },
@@ -82,7 +89,7 @@ export const FormField = ({
82
89
  label={label}
83
90
  inputOnly={inline}
84
91
  placeholder={placeholder}
85
- disabled={readonly}
92
+ readonly={readonly}
86
93
  {...inputProps}
87
94
  />
88
95
  );
@@ -102,9 +109,12 @@ export const FormField = ({
102
109
  format={format}
103
110
  label={label}
104
111
  placeholder={placeholder}
105
- disabled={readonly}
112
+ readonly={readonly}
106
113
  inputOnly={inline}
107
114
  onQueryRefOptions={onQueryRefOptions}
115
+ createOptionLabel={createOptionLabel}
116
+ createOptionIcon={createOptionIcon}
117
+ onCreateFromQuery={onCreateFromQuery}
108
118
  {...inputProps}
109
119
  />
110
120
  );
@@ -131,7 +141,7 @@ export const FormField = ({
131
141
  label={label}
132
142
  inputOnly={inline}
133
143
  placeholder={placeholder}
134
- disabled={readonly}
144
+ readonly={readonly}
135
145
  {...inputProps}
136
146
  />
137
147
  );
@@ -146,7 +156,7 @@ export const FormField = ({
146
156
  <SelectInput
147
157
  type={type}
148
158
  format={format}
149
- disabled={readonly}
159
+ readonly={readonly}
150
160
  inputOnly={inline}
151
161
  label={label}
152
162
  options={options.map((option) => ({ value: option, label: String(option) }))}
@@ -174,7 +184,11 @@ export const FormField = ({
174
184
  schema={Schema.make(typeLiteral)}
175
185
  path={path}
176
186
  readonly={readonly}
187
+ projection={projection}
177
188
  onQueryRefOptions={onQueryRefOptions}
189
+ createOptionLabel={createOptionLabel}
190
+ createOptionIcon={createOptionIcon}
191
+ onCreateFromQuery={onCreateFromQuery}
178
192
  Custom={Custom}
179
193
  lookupComponent={lookupComponent}
180
194
  />
@@ -186,36 +200,82 @@ export const FormField = ({
186
200
  return null;
187
201
  };
188
202
 
189
- export type FormFieldsProps = ThemedClassName<{
190
- testId?: string;
191
- readonly?: boolean;
192
- schema: Schema.Schema.All;
193
- /**
194
- * Path to the current object from the root. Used with nested forms.
195
- */
196
- path?: (string | number)[];
197
- filter?: (props: SchemaProperty<any>[]) => SchemaProperty<any>[];
198
- sort?: string[];
199
- lookupComponent?: ComponentLookup;
200
- /**
201
- * Map of custom renderers for specific properties.
202
- * Prefer lookupComponent for plugin specific input surfaces.
203
- */
204
- Custom?: Partial<Record<string, InputComponent>>;
205
- onQueryRefOptions?: QueryRefOptions;
206
- }>;
203
+ export type FormFieldsProps = ThemedClassName<
204
+ {
205
+ testId?: string;
206
+ schema: Schema.Schema.All;
207
+ /**
208
+ * Path to the current object from the root. Used with nested forms.
209
+ */
210
+ path?: (string | number)[];
211
+ filter?: (props: SchemaProperty<any>[]) => SchemaProperty<any>[];
212
+ sort?: string[];
213
+ /**
214
+ * Optional projection for projection-based field management
215
+ */
216
+ projection?: ProjectionModel;
217
+ lookupComponent?: ComponentLookup;
218
+ /**
219
+ * Map of custom renderers for specific properties.
220
+ * Prefer lookupComponent for plugin specific input surfaces.
221
+ */
222
+ Custom?: Partial<Record<string, InputComponent>>;
223
+ onQueryRefOptions?: QueryRefOptions;
224
+ } & Pick<FormFieldProps, 'readonly'> &
225
+ Pick<RefFieldProps, 'onQueryRefOptions' | 'createOptionLabel' | 'createOptionIcon' | 'onCreateFromQuery'>
226
+ >;
207
227
 
208
228
  export const FormFields = forwardRef<HTMLDivElement, FormFieldsProps>(
209
229
  (
210
- { classNames, testId, schema, path, filter, sort, readonly, lookupComponent, Custom, onQueryRefOptions },
230
+ {
231
+ classNames,
232
+ schema,
233
+ path,
234
+ filter,
235
+ sort,
236
+ projection,
237
+ readonly,
238
+ lookupComponent,
239
+ Custom,
240
+ onQueryRefOptions,
241
+ ...props
242
+ },
211
243
  forwardRef,
212
244
  ) => {
213
245
  const values = useFormValues(path);
214
246
  const properties = useMemo(() => {
215
247
  const props = getSchemaProperties(schema.ast, values);
248
+
249
+ // Use projection-based field management when view and projection are available
250
+ if (projection) {
251
+ const fieldProjections = projection.getFieldProjections();
252
+ const hiddenProperties = new Set(projection.getHiddenProperties());
253
+
254
+ // Filter properties to only include visible ones and order by projection
255
+ const visibleProps = props.filter((prop) => !hiddenProperties.has(prop.name));
256
+ const orderedProps: SchemaProperty<any>[] = [];
257
+
258
+ // Add properties in projection field order
259
+ for (const fieldProjection of fieldProjections) {
260
+ const fieldPath = String(fieldProjection.field.path);
261
+ const prop = visibleProps.find((p) => p.name === fieldPath);
262
+ if (prop) {
263
+ orderedProps.push(prop);
264
+ }
265
+ }
266
+
267
+ // Add any remaining properties not in projection
268
+ const projectionPaths = new Set(fieldProjections.map((fp) => String(fp.field.path)));
269
+ const remainingProps = visibleProps.filter((prop) => !projectionPaths.has(prop.name));
270
+ orderedProps.push(...remainingProps);
271
+
272
+ return orderedProps;
273
+ }
274
+
275
+ // Fallback to legacy filter/sort behavior
216
276
  const filtered = filter ? filter(props) : props;
217
277
  return sort ? filtered.sort((a, b) => sort.indexOf(a.name) - sort.indexOf(b.name)) : filtered;
218
- }, [schema, values, filter, sort]);
278
+ }, [schema, values, filter, sort, projection?.fields]);
219
279
 
220
280
  return (
221
281
  <div role='form' className={mx('is-full', classNames)} ref={forwardRef}>
@@ -227,9 +287,11 @@ export const FormFields = forwardRef<HTMLDivElement, FormFieldsProps>(
227
287
  property={property}
228
288
  path={[...(path ?? []), property.name]}
229
289
  readonly={readonly}
290
+ projection={projection}
230
291
  onQueryRefOptions={onQueryRefOptions}
231
292
  lookupComponent={lookupComponent}
232
293
  Custom={Custom}
294
+ {...props}
233
295
  />
234
296
  );
235
297
  })
@@ -3,19 +3,19 @@
3
3
  //
4
4
 
5
5
  import React, {
6
- createContext,
6
+ type FocusEvent,
7
+ type PropsWithChildren,
7
8
  type RefObject,
9
+ createContext,
10
+ useCallback,
8
11
  useContext,
9
12
  useEffect,
10
13
  useMemo,
11
- type FocusEvent,
12
- type PropsWithChildren,
13
- useCallback,
14
14
  } from 'react';
15
15
 
16
16
  import { raise } from '@dxos/debug';
17
17
  import { type BaseObject, getValue } from '@dxos/echo-schema';
18
- import { createJsonPath, type SimpleType } from '@dxos/effect';
18
+ import { type SimpleType, createJsonPath } from '@dxos/effect';
19
19
 
20
20
  import { type FormHandler, type FormOptions, useForm } from '../../hooks';
21
21
 
@@ -19,9 +19,12 @@ export type InputProps = {
19
19
  type: SimpleType;
20
20
  format?: FormatEnum;
21
21
  label: string;
22
- disabled?: boolean;
23
22
  placeholder?: string;
24
23
  inputOnly?: boolean;
24
+ /**
25
+ * Specifies the readonly variant: either disabled inputs, elements indicating they are usually editable but currently are not; or `static`, a field’s representation as regular content without signifiers that it is ever editable.
26
+ */
27
+ readonly?: 'disabled-input' | 'static' | false;
25
28
  } & FormInputStateProps;
26
29
 
27
30
  /**
@@ -3,38 +3,50 @@
3
3
  //
4
4
 
5
5
  import { type SchemaAST } from 'effect';
6
- import React, { useCallback, useMemo, type FocusEvent } from 'react';
6
+ import React, { useCallback, useMemo, useState } from 'react';
7
7
 
8
8
  import {
9
9
  Expando,
10
- getTypeAnnotation,
11
10
  Ref,
12
11
  ReferenceAnnotationId,
13
12
  type ReferenceAnnotationValue,
13
+ type TypeAnnotation,
14
+ getTypeAnnotation,
14
15
  } from '@dxos/echo-schema';
15
16
  import { findAnnotation } from '@dxos/effect';
16
17
  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';
18
+ import { type DxTagPickerItemClick } from '@dxos/lit-ui';
19
+ import { DxAnchor } from '@dxos/lit-ui/react';
20
+ 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';
20
23
  import { descriptionText, mx } from '@dxos/react-ui-theme';
21
- import { isNonNullable } from '@dxos/util';
24
+ import { type MaybePromise, isNonNullable } from '@dxos/util';
22
25
 
23
- import { TextInput } from './Defaults';
24
- import { InputHeader, type InputProps } from './Input';
25
26
  import { type QueryRefOptions, useQueryRefOptions } from '../../hooks';
26
27
  import { translationKey } from '../../translations';
27
28
 
28
- type RefFieldProps = InputProps & {
29
+ import { TextInput } from './Defaults';
30
+ import { InputHeader, type InputProps } from './Input';
31
+
32
+ export type RefFieldProps = InputProps & {
29
33
  ast?: SchemaAST.AST;
30
34
  array?: boolean;
31
35
  onQueryRefOptions?: QueryRefOptions;
36
+ createOptionLabel?: [string, { ns: string }];
37
+ createOptionIcon?: string;
38
+ onCreateFromQuery?: (type: TypeAnnotation, query: string) => MaybePromise<void>;
39
+ };
40
+
41
+ // TODO(thure): Is this a standard that should be better canonized?
42
+ const isRefSnapShot = (val: any): val is { '/': string } => {
43
+ return typeof val === 'object' && typeof (val as any)?.['/'] === 'string';
32
44
  };
33
45
 
34
46
  export const RefField = ({
35
47
  type,
36
48
  label,
37
- disabled,
49
+ readonly,
38
50
  placeholder,
39
51
  inputOnly,
40
52
  array,
@@ -42,6 +54,9 @@ export const RefField = ({
42
54
  getValue,
43
55
  onBlur,
44
56
  onQueryRefOptions,
57
+ createOptionLabel,
58
+ createOptionIcon,
59
+ onCreateFromQuery,
45
60
  onValueChange,
46
61
  ...restInputProps
47
62
  }: RefFieldProps) => {
@@ -56,17 +71,18 @@ export const RefField = ({
56
71
  // If ref type is expando, fall back to taking a DXN in string format.
57
72
  return (
58
73
  <RefFieldFallback
59
- {...{ type, label, placeholder, disabled, inputOnly, getValue, onBlur, onValueChange, ...restInputProps }}
74
+ {...{ type, label, placeholder, readonly, inputOnly, getValue, onBlur, onValueChange, ...restInputProps }}
60
75
  />
61
76
  );
62
77
  }
63
78
 
64
- const handleGetValue = useCallback((): TagPickerItemData[] => {
79
+ const handleGetValue = useCallback(() => {
65
80
  const formValue = getValue();
66
81
 
67
82
  const unknownToRefOption = (val: unknown) => {
68
- if (Ref.isRef(val)) {
69
- const dxnString = val.dxn.toString();
83
+ const isRef = Ref.isRef(val);
84
+ if (isRef || isRefSnapShot(val)) {
85
+ const dxnString = isRef ? val.dxn.toString() : val['/'];
70
86
  const matchingOption = availableOptions.find((option) => option.id === dxnString);
71
87
  if (matchingOption) {
72
88
  return matchingOption;
@@ -87,15 +103,6 @@ export const RefField = ({
87
103
  return [];
88
104
  }, [getValue, availableOptions, array]);
89
105
 
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
106
  const handleUpdate = useCallback(
100
107
  (ids: string[]) => {
101
108
  if (ids.length === 0) {
@@ -123,8 +130,6 @@ export const RefField = ({
123
130
  [availableOptions, array, onValueChange],
124
131
  );
125
132
 
126
- const tagPickerMode: TagPickerMode = useMemo(() => (array ? 'multi-select' : 'single-select'), [array]);
127
-
128
133
  if (!refTypeInfo) {
129
134
  return null;
130
135
  }
@@ -132,32 +137,113 @@ export const RefField = ({
132
137
  const { status, error } = restInputProps.getStatus();
133
138
 
134
139
  const items = handleGetValue();
140
+ 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
+ const toggleSelect = useCallback(
151
+ (id: string) => {
152
+ if (array) {
153
+ const nextIds = selectedIds.includes(id) ? selectedIds.filter((x) => x !== id) : [...selectedIds, id];
154
+ handleUpdate(nextIds);
155
+ } else {
156
+ if (selectedIds[0] === id) {
157
+ handleUpdate([]);
158
+ } else {
159
+ handleUpdate([id]);
160
+ }
161
+ }
162
+ },
163
+ [array, selectedIds, handleUpdate],
164
+ );
135
165
 
136
166
  // 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 : (
167
+ return readonly && items.length < 1 ? null : (
138
168
  <Input.Root validationValence={status}>
139
169
  {!inputOnly && <InputHeader error={error} label={label} />}
140
170
  <div data-no-submit>
141
- {disabled ? (
171
+ {readonly ? (
142
172
  items.length < 1 ? (
143
173
  <p className={mx(descriptionText, 'mbe-2')}>{t('empty readonly ref field label')}</p>
144
174
  ) : (
145
175
  items.map((item) => (
146
- <DxRefTag key={item.id} refid={item.id} rootclassname='mie-1' style={{ marginInlineEnd: '.25rem' }}>
176
+ <DxAnchor key={item.id} refid={item.id} rootclassname='mie-1'>
147
177
  {item.label}
148
- </DxRefTag>
178
+ </DxAnchor>
149
179
  ))
150
180
  )
151
181
  ) : (
152
- <TagPicker
153
- readonly={disabled}
154
- items={items}
155
- mode={tagPickerMode}
156
- onBlur={(event) => onBlur(event as unknown as FocusEvent<HTMLElement>)}
157
- onUpdate={handleUpdate}
158
- onSearch={handleSearch}
159
- classNames='rounded-sm bg-inputSurface p-1.5'
160
- />
182
+ <PopoverCombobox.Root>
183
+ <PopoverCombobox.Trigger asChild>
184
+ <Button variant='ghost' classNames='is-full text-start gap-2'>
185
+ <div role='none' className='grow'>
186
+ {items?.length ? (
187
+ items?.map((item) => (
188
+ <TagPickerItem
189
+ key={item.id}
190
+ itemId={item.id}
191
+ label={item.label}
192
+ {...(item.hue ? { hue: item.hue } : {})}
193
+ removeLabel={t('remove item label')}
194
+ onItemClick={(event: DxTagPickerItemClick) => {
195
+ if (event.action === 'remove') {
196
+ toggleSelect(item.id);
197
+ }
198
+ }}
199
+ />
200
+ ))
201
+ ) : (
202
+ <span className='text-description'>
203
+ {placeholder ?? t('ref field placeholder', { count: array ? 99 : 1 })}
204
+ </span>
205
+ )}
206
+ </div>
207
+ <Icon size={3} icon='ph--caret-down--bold' />
208
+ </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>
161
247
  )}
162
248
  </div>
163
249
  {inputOnly && <Input.DescriptionAndValidation>{error}</Input.DescriptionAndValidation>}
@@ -168,7 +254,7 @@ export const RefField = ({
168
254
  const RefFieldFallback = ({
169
255
  type,
170
256
  label,
171
- disabled,
257
+ readonly,
172
258
  placeholder,
173
259
  inputOnly,
174
260
  getValue,
@@ -202,7 +288,7 @@ const RefFieldFallback = ({
202
288
  <TextInput
203
289
  type={type}
204
290
  label={label}
205
- disabled={disabled}
291
+ readonly={readonly}
206
292
  placeholder={placeholder}
207
293
  inputOnly={inputOnly}
208
294
  getValue={handleGetValue as <V>() => V | undefined}
@@ -2,16 +2,16 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import React, { useCallback, useState, useEffect, useMemo, type ChangeEvent } from 'react';
5
+ import React, { type ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
6
6
 
7
- import { type GeoPoint, GeoLocation } from '@dxos/echo-schema';
7
+ import { GeoLocation, type GeoPoint } from '@dxos/echo-schema';
8
8
  import { Input, useTranslation } from '@dxos/react-ui';
9
9
  import { safeParseFloat } from '@dxos/util';
10
10
 
11
11
  import { translationKey } from '../../../translations';
12
12
  import { InputHeader, type InputProps } from '../Input';
13
13
 
14
- export const GeoPointInput = ({ type, label, disabled, getStatus, getValue, onValueChange, onBlur }: InputProps) => {
14
+ export const GeoPointInput = ({ type, label, readonly, getStatus, getValue, onValueChange, onBlur }: InputProps) => {
15
15
  const { t } = useTranslation(translationKey);
16
16
  const { status, error } = getStatus();
17
17
  const geoPoint = useMemo<GeoPoint>(() => getValue<GeoPoint>() ?? [0, 0], [getValue]);
@@ -48,24 +48,32 @@ export const GeoPointInput = ({ type, label, disabled, getStatus, getValue, onVa
48
48
  <Input.Root validationValence={status}>
49
49
  <InputHeader error={error} label={label} />
50
50
  <div role='none' className='grid grid-cols-2 gap-2'>
51
- <Input.TextInput
52
- type='text'
53
- pattern='^-?[0-9]*\.?[0-9]*$'
54
- disabled={disabled}
55
- placeholder={t('placeholder latitude')}
56
- value={latitudeText}
57
- onChange={handleCoordinateChange('latitude', setLatitudeText)}
58
- onBlur={onBlur}
59
- />
60
- <Input.TextInput
61
- type='text'
62
- pattern='^-?[0-9]*\.?[0-9]*$'
63
- disabled={disabled}
64
- placeholder={t('placeholder longitude')}
65
- value={longitudeText}
66
- onChange={handleCoordinateChange('longitude', setLongitudeText)}
67
- onBlur={onBlur}
68
- />
51
+ <div role='none'>
52
+ <Input.Label>{t('latitude label')}</Input.Label>
53
+ <Input.TextInput
54
+ type='number'
55
+ step='0.00001'
56
+ min='-90'
57
+ max='90'
58
+ disabled={!!readonly}
59
+ value={latitudeText}
60
+ onChange={handleCoordinateChange('latitude', setLatitudeText)}
61
+ onBlur={onBlur}
62
+ />
63
+ </div>
64
+ <div role='none'>
65
+ <Input.Label>{t('longitude label')}</Input.Label>
66
+ <Input.TextInput
67
+ type='number'
68
+ step='0.00001'
69
+ min='-180'
70
+ max='180'
71
+ disabled={!!readonly}
72
+ value={longitudeText}
73
+ onChange={handleCoordinateChange('longitude', setLongitudeText)}
74
+ onBlur={onBlur}
75
+ />
76
+ </div>
69
77
  </div>
70
78
  </Input.Root>
71
79
  );
@@ -4,18 +4,19 @@
4
4
 
5
5
  import '@dxos-theme';
6
6
 
7
- import { type Meta, type StoryObj } from '@storybook/react';
7
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
8
8
  import { Schema } from 'effect';
9
9
  import React, { useState } from 'react';
10
10
 
11
- import { SelectOptionSchema, type SelectOption } from '@dxos/echo-schema';
11
+ import { type SelectOption, SelectOptionSchema } from '@dxos/echo-schema';
12
12
  import { withLayout, withTheme } from '@dxos/storybook-utils';
13
13
 
14
- import { SelectOptionInput } from './SelectOptionsInput';
15
- import translations from '../../../translations';
14
+ import { translations } from '../../../translations';
16
15
  import { TestLayout, TestPanel } from '../../testing';
17
16
  import { Form } from '../Form';
18
17
 
18
+ import { SelectOptionInput } from './SelectOptionsInput';
19
+
19
20
  const schema = Schema.Struct({
20
21
  options: Schema.Array(SelectOptionSchema).pipe(Schema.mutable),
21
22
  }).pipe(Schema.mutable);
@@ -44,18 +45,18 @@ const DefaultStory = () => {
44
45
  );
45
46
  };
46
47
 
47
- const meta: Meta = {
48
+ const meta = {
48
49
  title: 'ui/react-ui-form/SelectOptionsInput',
49
- component: SelectOptionInput,
50
+ component: SelectOptionInput as any,
50
51
  render: DefaultStory,
51
52
  decorators: [withTheme, withLayout({ fullscreen: true })],
52
53
  parameters: {
53
54
  translations,
54
55
  },
55
- };
56
+ } satisfies Meta<typeof DefaultStory>;
56
57
 
57
58
  export default meta;
58
59
 
59
- type Story = StoryObj;
60
+ type Story = StoryObj<typeof meta>;
60
61
 
61
62
  export const Default: Story = {};