@dxos/react-ui-form 0.7.5-main.9d26e3a → 0.7.5-main.e9bb01b

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 (70) hide show
  1. package/dist/lib/browser/index.mjs +743 -466
  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 +777 -515
  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 +743 -466
  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/FieldEditor/FieldEditor.d.ts.map +1 -1
  11. package/dist/types/src/components/Form/ArrayField.d.ts +16 -0
  12. package/dist/types/src/components/Form/ArrayField.d.ts.map +1 -0
  13. package/dist/types/src/components/Form/Defaults.d.ts +5 -6
  14. package/dist/types/src/components/Form/Defaults.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/Form.d.ts +13 -16
  16. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  17. package/dist/types/src/components/Form/Form.stories.d.ts +2 -1
  18. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/FormActions.d.ts +7 -0
  20. package/dist/types/src/components/Form/FormActions.d.ts.map +1 -0
  21. package/dist/types/src/components/Form/FormContent.d.ts +26 -0
  22. package/dist/types/src/components/Form/FormContent.d.ts.map +1 -0
  23. package/dist/types/src/components/Form/FormContext.d.ts +20 -0
  24. package/dist/types/src/components/Form/FormContext.d.ts.map +1 -0
  25. package/dist/types/src/components/Form/Input.d.ts +5 -6
  26. package/dist/types/src/components/Form/Input.d.ts.map +1 -1
  27. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +1 -2
  28. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +1 -1
  29. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +4 -0
  30. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +1 -0
  31. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +7 -0
  32. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +1 -0
  33. package/dist/types/src/components/Form/custom/TupleInput.d.ts +1 -2
  34. package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +1 -1
  35. package/dist/types/src/components/Form/custom/index.d.ts +1 -0
  36. package/dist/types/src/components/Form/custom/index.d.ts.map +1 -1
  37. package/dist/types/src/components/Form/factory.d.ts +2 -2
  38. package/dist/types/src/components/Form/factory.d.ts.map +1 -1
  39. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +2 -1
  40. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  41. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  42. package/dist/types/src/components/deprecated/FormInput.d.ts +5 -0
  43. package/dist/types/src/components/deprecated/FormInput.d.ts.map +1 -1
  44. package/dist/types/src/hooks/useForm.d.ts +9 -10
  45. package/dist/types/src/hooks/useForm.d.ts.map +1 -1
  46. package/dist/types/src/translations.d.ts +6 -0
  47. package/dist/types/src/translations.d.ts.map +1 -1
  48. package/dist/types/tsconfig.tsbuildinfo +1 -1
  49. package/package.json +25 -24
  50. package/src/components/FieldEditor/FieldEditor.stories.tsx +1 -1
  51. package/src/components/FieldEditor/FieldEditor.tsx +58 -40
  52. package/src/components/Form/ArrayField.tsx +111 -0
  53. package/src/components/Form/Defaults.tsx +18 -33
  54. package/src/components/Form/Form.stories.tsx +15 -5
  55. package/src/components/Form/Form.tsx +39 -330
  56. package/src/components/Form/FormActions.tsx +41 -0
  57. package/src/components/Form/FormContent.tsx +173 -0
  58. package/src/components/Form/FormContext.tsx +48 -0
  59. package/src/components/Form/Input.tsx +5 -6
  60. package/src/components/Form/custom/GeoPointInput.tsx +6 -15
  61. package/src/components/Form/custom/SelectOptionsInput.stories.tsx +58 -0
  62. package/src/components/Form/custom/SelectOptionsInput.tsx +192 -0
  63. package/src/components/Form/custom/TupleInput.tsx +5 -9
  64. package/src/components/Form/custom/index.ts +1 -0
  65. package/src/components/Form/factory.tsx +2 -5
  66. package/src/components/ViewEditor/ViewEditor.stories.tsx +25 -4
  67. package/src/components/ViewEditor/ViewEditor.tsx +14 -6
  68. package/src/components/deprecated/FormInput.tsx +19 -3
  69. package/src/hooks/useForm.ts +54 -47
  70. package/src/translations.ts +8 -0
@@ -2,29 +2,28 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { pipe } from 'effect';
6
- import { capitalize } from 'effect/String';
7
- import React, { useEffect, useMemo } from 'react';
5
+ import React, { type ReactElement, useMemo } from 'react';
8
6
 
9
- import { AST, type BaseObject, S, type PropertyKey, type FormatEnum } from '@dxos/echo-schema';
10
- import { findNode, getDiscriminatedType, isDiscriminatedUnion, SimpleType } from '@dxos/effect';
11
- import { log } from '@dxos/log';
12
- import { IconButton, type ThemedClassName, useTranslation } from '@dxos/react-ui';
7
+ import { type BaseObject, type S, type PropertyKey } from '@dxos/echo-schema';
8
+ import { type ThemedClassName } from '@dxos/react-ui';
13
9
  import { mx } from '@dxos/react-ui-theme';
14
- import { getSchemaProperties, type SchemaProperty } from '@dxos/schema';
15
- import { isNotFalsy } from '@dxos/util';
10
+ import { type SchemaProperty } from '@dxos/schema';
16
11
 
17
- import { SelectInput } from './Defaults';
18
- import { InputHeader, type InputComponent } from './Input';
19
- import { getInputComponent } from './factory';
20
- import { type FormHandler, type FormOptions, useForm } from '../../hooks';
21
- import { translationKey } from '../../translations';
12
+ import { FormActions } from './FormActions';
13
+ import { FormFields } from './FormContent';
14
+ import { FormProvider } from './FormContext';
15
+ import { type InputProps, type InputComponent } from './Input';
16
+ import { type FormOptions } from '../../hooks';
22
17
 
23
- // TODO(burdon): Rename package react-ui-form; delete react-ui-card.
18
+ export type PropsFilter<T extends BaseObject> = (props: SchemaProperty<T>[]) => SchemaProperty<T>[];
24
19
 
25
- const padding = 'px-2';
20
+ export type ComponentLookup = (args: {
21
+ prop: string;
22
+ schema: S.Schema<any>;
23
+ inputProps: InputProps;
24
+ }) => ReactElement | undefined;
26
25
 
27
- export type PropsFilter<T extends BaseObject> = (props: SchemaProperty<T>[]) => SchemaProperty<T>[];
26
+ export type CustomInputMap = Partial<Record<string, InputComponent>>;
28
27
 
29
28
  export type FormProps<T extends BaseObject> = ThemedClassName<
30
29
  {
@@ -42,18 +41,15 @@ export type FormProps<T extends BaseObject> = ThemedClassName<
42
41
  autoSave?: boolean;
43
42
  testId?: string;
44
43
  onCancel?: () => void;
45
-
44
+ lookupComponent?: ComponentLookup;
46
45
  /**
47
46
  * Map of custom renderers for specific properties.
47
+ * Prefer lookupComponent for plugin specific input surfaces.
48
48
  */
49
- Custom?: Partial<Record<string, InputComponent<T>>>;
49
+ Custom?: CustomInputMap;
50
50
  } & Pick<FormOptions<T>, 'schema' | 'onValuesChanged' | 'onValidate' | 'onSave'>
51
51
  >;
52
52
 
53
- /**
54
- * General purpose form component that displays field controls based on the given schema.
55
- */
56
- // TODO(burdon): Area to show general validation errors.
57
53
  export const Form = <T extends BaseObject>({
58
54
  classNames,
59
55
  schema,
@@ -68,319 +64,32 @@ export const Form = <T extends BaseObject>({
68
64
  onValidate,
69
65
  onSave,
70
66
  onCancel,
67
+ lookupComponent,
71
68
  Custom,
72
69
  }: FormProps<T>) => {
73
70
  const onValid = useMemo(() => (autoSave ? onSave : undefined), [autoSave, onSave]);
74
- const { canSave, values, errors, handleSave, ...inputProps } = useForm<T>({
75
- schema,
76
- initialValues,
77
- onValuesChanged,
78
- onValidate,
79
- onValid,
80
- onSave,
81
- });
82
-
83
- // Filter and sort props.
84
- // TODO(burdon): Move into useForm?
85
- const properties = useMemo(() => {
86
- const props = getSchemaProperties<T>(schema.ast, values);
87
- const filtered = filter ? filter(props) : props;
88
- const findIndex = (props: PropertyKey<T>[], prop: PropertyKey<T>) => {
89
- const idx = props.findIndex((p) => p === prop);
90
- return idx === -1 ? Infinity : idx;
91
- };
92
-
93
- return sort ? filtered.sort(({ name: a }, { name: b }) => findIndex(sort, a) - findIndex(sort, b)) : filtered;
94
- }, [schema, filter, values]);
95
-
96
- // TODO(burdon): Highlight in UX.
97
- useEffect(() => {
98
- if (errors && Object.keys(errors).length) {
99
- log.warn('validation', { errors });
100
- }
101
- }, [errors]);
102
71
 
103
72
  return (
104
- <div role='form' className={mx('flex flex-col w-full gap-2 py-2', classNames)} data-testid={testId}>
105
- {properties
106
- .map((property) => {
107
- const { ast, name, type, format, title, description, examples, options, array } = property;
108
- const key = [...path, name];
109
- const label = title ?? pipe(name, capitalize);
110
- const placeholder = examples?.length ? `Example: "${examples[0]}"` : description;
111
-
112
- // Get generic input.
113
- // TODO(ZaymonFC): We might need to switch this to using globs since we're now indexing into arrays.
114
- let InputComponent = Custom?.[key.join('.')];
115
- if (!InputComponent) {
116
- // Select.
117
- if (options) {
118
- return (
119
- <div key={name} role='none' className={padding}>
120
- <SelectInput
121
- type={type}
122
- format={format}
123
- property={name}
124
- disabled={readonly}
125
- label={label}
126
- options={options.map((option) => ({ value: option, label: String(option) }))}
127
- placeholder={placeholder}
128
- {...inputProps}
129
- />
130
- </div>
131
- );
132
- }
133
-
134
- InputComponent = getInputComponent<T>(type, format);
135
-
136
- // TODO(ZaymonFC): Array handling is inelegant. We need to push this complexity down into SDK/Schema.
137
- if (array) {
138
- return (
139
- <ArrayField
140
- key={name}
141
- name={name}
142
- type={type}
143
- array={true}
144
- label={label}
145
- ast={ast}
146
- values={values}
147
- format={format}
148
- readonly={readonly}
149
- placeholder={placeholder}
150
- inputProps={inputProps}
151
- Custom={Custom}
152
- />
153
- );
154
- }
155
- }
156
-
157
- if (!InputComponent) {
158
- // Recursively render form.
159
- if (type === 'object') {
160
- const baseNode = findNode(ast, isDiscriminatedUnion);
161
- const typeLiteral = baseNode
162
- ? getDiscriminatedType(baseNode, values[name] as any)
163
- : findNode(ast, AST.isTypeLiteral);
164
-
165
- if (typeLiteral) {
166
- const schema = S.make(typeLiteral);
167
- return (
168
- <div key={name} role='none'>
169
- <div className={padding}>{label}</div>
170
- <Form<any>
171
- schema={schema}
172
- path={key}
173
- values={values[name] ?? {}}
174
- onValuesChanged={(childValues) => {
175
- inputProps.onValueChange(name, 'object', childValues);
176
- }}
177
- Custom={Custom as any}
178
- />
179
- </div>
180
- );
181
- }
182
- }
183
-
184
- log('no renderer for property', { name, type });
185
- return null;
186
- }
187
-
188
- return (
189
- <div key={name} role='none' className={padding}>
190
- <InputComponent
191
- type={type}
192
- format={format}
193
- property={name}
194
- disabled={readonly}
195
- label={label}
196
- placeholder={placeholder}
197
- {...inputProps}
198
- />
199
- </div>
200
- );
201
- })
202
- .filter(isNotFalsy)}
203
- {(onCancel || onSave) && !autoSave && (
204
- <FormActions onCancel={onCancel} onSubmit={handleSave} canSubmit={canSave} readonly={readonly} />
205
- )}
206
- </div>
207
- );
208
- };
209
-
210
- type ArrayFieldProps<T extends BaseObject> = {
211
- name: PropertyKey<T>;
212
- type: SimpleType;
213
- array: true;
214
- label: string;
215
- ast: AST.AST;
216
- values: Record<string, any>;
217
- format?: FormatEnum;
218
- readonly?: boolean;
219
- placeholder?: string;
220
- inputProps: Pick<FormHandler<T>, 'getStatus' | 'getValue' | 'onValueChange' | 'onBlur'>;
221
- Custom?: Partial<Record<string, InputComponent<T>>>;
222
- };
223
-
224
- const ArrayField = <T extends BaseObject>({
225
- name,
226
- type,
227
- label,
228
- ast,
229
- values,
230
- format,
231
- readonly,
232
- placeholder,
233
- inputProps,
234
- Custom,
235
- }: ArrayFieldProps<T>) => {
236
- const { t } = useTranslation(translationKey);
237
- const arrayValues = (values[name] ?? []) as any[];
238
- const key = [name];
239
-
240
- // TODO(ZaymonFC): Should this unwrapping happen at a lower level?
241
- const tupleType = findNode(ast, AST.isTupleType);
242
- const elementType = (tupleType as AST.TupleType | undefined)?.rest[0]?.type;
243
-
244
- if (type === 'object' && elementType) {
245
- const baseNode = findNode(elementType, isDiscriminatedUnion);
246
- const typeLiteral = baseNode
247
- ? getDiscriminatedType(baseNode, values[name] as any)
248
- : findNode(elementType, AST.isTypeLiteral);
249
-
250
- if (typeLiteral) {
251
- const schema = S.make(typeLiteral);
252
-
253
- return (
254
- <div key={name} role='none' className={mx(padding)}>
255
- <InputHeader>{label}</InputHeader>
256
- <div role='none' className='flex flex-col gap-1'>
257
- {arrayValues.map((value, index) => {
258
- return (
259
- <div key={index} role='none' className='flex items-center gap-1'>
260
- <div role='none' className='flex-1'>
261
- <Form<any>
262
- schema={schema}
263
- path={[...key, `[${index}]`]}
264
- values={value}
265
- onValuesChanged={(childValues) => {
266
- const newValues = [...arrayValues];
267
- newValues[index] = childValues;
268
- inputProps.onValueChange(name, 'object', newValues);
269
- }}
270
- Custom={Custom as any}
271
- />
272
- </div>
273
- <IconButton
274
- icon='ph--trash--regular'
275
- iconOnly
276
- label={t('button remove')}
277
- onClick={() => {
278
- const newValues = arrayValues.filter((_, i) => i !== index);
279
- inputProps.onValueChange(name, type, newValues);
280
- }}
281
- />
282
- </div>
283
- );
284
- })}
285
- </div>
286
- <div role='none' className='flex justify-between items-center plb-1'>
287
- <IconButton
288
- icon='ph--plus--regular'
289
- iconOnly
290
- label={t('button add')}
291
- onClick={() => {
292
- const newValues = [...arrayValues, {}];
293
- inputProps.onValueChange(name, type, newValues);
294
- }}
295
- />
296
- </div>
297
- </div>
298
- );
299
- }
300
- return (
301
- <div key={name} role='none'>
302
- Nested form not supported in arrays, yet.
303
- </div>
304
- );
305
- }
306
-
307
- const InputComponent = getInputComponent<T>(type, format);
308
- if (!InputComponent) {
309
- return null;
310
- }
311
-
312
- return (
313
- <div role='none' key='meta-input' className={mx(padding)}>
314
- <InputHeader>{label}</InputHeader>
315
- <div role='none' className='flex flex-col gap-1'>
316
- {arrayValues.map((_, index) => (
317
- <div key={index} role='none' className='flex items-start gap-1'>
318
- <div role='none' className='flex-1'>
319
- <InputComponent
320
- type={type}
321
- format={format}
322
- label={label}
323
- inputOnly
324
- property={`${name}[${index}]` as PropertyKey<T>}
325
- disabled={readonly}
326
- placeholder={placeholder}
327
- {...inputProps}
328
- />
329
- </div>
330
- <IconButton
331
- icon='ph--trash--regular'
332
- iconOnly
333
- label={t('button remove')}
334
- onClick={() => {
335
- const newValues = arrayValues.filter((_, i) => i !== index);
336
- inputProps.onValueChange(name, type, newValues);
337
- }}
338
- />
339
- </div>
340
- ))}
341
- </div>
342
- <div role='none' className='flex justify-between items-center plb-1'>
343
- <IconButton
344
- icon='ph--plus--regular'
345
- iconOnly
346
- label={t('button add')}
347
- onClick={() => {
348
- const newValues = [...arrayValues, SimpleType.getDefaultValue(type)];
349
- inputProps.onValueChange(name, type, newValues);
350
- }}
73
+ <FormProvider
74
+ schema={schema}
75
+ initialValues={initialValues}
76
+ onValuesChanged={onValuesChanged}
77
+ onValidate={onValidate}
78
+ onValid={onValid}
79
+ onSave={onSave}
80
+ >
81
+ <div role='none' className={mx('p-2', classNames)} data-testid={testId}>
82
+ <FormFields
83
+ schema={schema}
84
+ path={path}
85
+ readonly={readonly}
86
+ filter={filter}
87
+ sort={sort}
88
+ lookupComponent={lookupComponent}
89
+ Custom={Custom}
351
90
  />
91
+ {(onCancel || onSave) && !autoSave && <FormActions onCancel={onCancel} readonly={readonly} />}
352
92
  </div>
353
- </div>
354
- );
355
- };
356
-
357
- type FormActionsProps = {
358
- readonly?: boolean;
359
- canSubmit: boolean;
360
- onCancel?: () => void;
361
- onSubmit: () => void;
362
- };
363
-
364
- export const FormActions = ({ onCancel, onSubmit, canSubmit, readonly }: FormActionsProps) => {
365
- const { t } = useTranslation(translationKey);
366
-
367
- return (
368
- <div role='none' className='flex justify-center'>
369
- <div role='none' className={mx(onCancel && !readonly && 'grid grid-cols-2 gap-2')}>
370
- {onCancel && !readonly && (
371
- <IconButton data-testid='cancel-button' icon='ph--x--regular' label={t('button cancel')} onClick={onCancel} />
372
- )}
373
- {onSubmit && (
374
- <IconButton
375
- type='submit'
376
- data-testid='save-button'
377
- disabled={!canSubmit}
378
- icon='ph--check--regular'
379
- label={t('button save')}
380
- onClick={onSubmit}
381
- />
382
- )}
383
- </div>
384
- </div>
93
+ </FormProvider>
385
94
  );
386
95
  };
@@ -0,0 +1,41 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React from 'react';
6
+
7
+ import { IconButton, useTranslation } from '@dxos/react-ui';
8
+ import { mx } from '@dxos/react-ui-theme';
9
+
10
+ import { useFormContext } from './FormContext';
11
+ import { translationKey } from '../../translations';
12
+
13
+ export type FormActionsProps = {
14
+ readonly?: boolean;
15
+ onCancel?: () => void;
16
+ };
17
+
18
+ export const FormActions = ({ onCancel, readonly }: FormActionsProps) => {
19
+ const { t } = useTranslation(translationKey);
20
+ const { canSave, handleSave } = useFormContext();
21
+
22
+ return (
23
+ <div role='none' className='flex justify-center'>
24
+ <div role='none' className={mx(onCancel && !readonly && 'grid grid-cols-2 gap-2')}>
25
+ {onCancel && !readonly && (
26
+ <IconButton data-testid='cancel-button' icon='ph--x--regular' label={t('button cancel')} onClick={onCancel} />
27
+ )}
28
+ {handleSave && (
29
+ <IconButton
30
+ type='submit'
31
+ data-testid='save-button'
32
+ disabled={!canSave}
33
+ icon='ph--check--regular'
34
+ label={t('button save')}
35
+ onClick={handleSave}
36
+ />
37
+ )}
38
+ </div>
39
+ </div>
40
+ );
41
+ };
@@ -0,0 +1,173 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import { pipe } from 'effect';
6
+ import { capitalize } from 'effect/String';
7
+ import React, { useMemo } from 'react';
8
+
9
+ import { AST, S } from '@dxos/echo-schema';
10
+ import { createJsonPath, findNode, getDiscriminatedType, isDiscriminatedUnion } from '@dxos/effect';
11
+ import { mx } from '@dxos/react-ui-theme';
12
+ import { getSchemaProperties, type SchemaProperty } from '@dxos/schema';
13
+ import { isNotFalsy } from '@dxos/util';
14
+
15
+ import { ArrayField } from './ArrayField';
16
+ import { SelectInput } from './Defaults';
17
+ import { type ComponentLookup } from './Form';
18
+ import { useInputProps, useFormValues } from './FormContext';
19
+ import { type InputComponent } from './Input';
20
+ 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
+ };
31
+
32
+ export type FormFieldProps = {
33
+ property: SchemaProperty<any>;
34
+ path?: (string | number)[];
35
+ readonly?: boolean;
36
+ /** Used to indicate if input should be presented inline (e.g. for array items). */
37
+ inline?: boolean;
38
+ lookupComponent?: ComponentLookup;
39
+ Custom?: Partial<Record<string, InputComponent>>;
40
+ };
41
+
42
+ export const FormField = ({ property, path, readonly, inline, lookupComponent, Custom }: FormFieldProps) => {
43
+ const inputProps = useInputProps(path);
44
+ const { ast, name, type, format, title, description, options, examples, array } = property;
45
+
46
+ const label = useMemo(() => title ?? pipe(name, capitalize), [title, name]);
47
+ const placeholder = useMemo(
48
+ () => (examples?.length ? `Example: "${examples[0]}"` : description),
49
+ [examples, description],
50
+ );
51
+
52
+ const FoundComponent = lookupComponent?.({
53
+ prop: name,
54
+ schema: S.make(ast),
55
+ inputProps: {
56
+ type,
57
+ format,
58
+ label,
59
+ disabled: readonly,
60
+ placeholder,
61
+ ...inputProps,
62
+ },
63
+ });
64
+
65
+ if (FoundComponent) {
66
+ return <div role='none'>{FoundComponent}</div>;
67
+ }
68
+
69
+ const jsonPath = createJsonPath(path ?? []);
70
+ const CustomComponent = Custom?.[jsonPath];
71
+ if (CustomComponent) {
72
+ return (
73
+ // TODO(ZaymonFC): We should try to remove this div wrapper.
74
+ <div role='none'>
75
+ <CustomComponent
76
+ type={type}
77
+ format={format}
78
+ label={label}
79
+ inputOnly={inline}
80
+ placeholder={placeholder}
81
+ disabled={readonly}
82
+ {...inputProps}
83
+ />
84
+ </div>
85
+ );
86
+ }
87
+
88
+ if (array) {
89
+ return <ArrayField property={property} path={path} inputProps={inputProps} readonly={readonly} Custom={Custom} />;
90
+ }
91
+
92
+ const InputComponent = getInputComponent(type, format);
93
+ if (InputComponent) {
94
+ return (
95
+ <div role='none'>
96
+ <InputComponent
97
+ type={type}
98
+ format={format}
99
+ label={label}
100
+ inputOnly={inline}
101
+ placeholder={placeholder}
102
+ disabled={readonly}
103
+ {...inputProps}
104
+ />
105
+ </div>
106
+ );
107
+ }
108
+
109
+ if (options) {
110
+ return (
111
+ <div role='none'>
112
+ <SelectInput
113
+ type={type}
114
+ format={format}
115
+ disabled={readonly}
116
+ inputOnly={inline}
117
+ label={label}
118
+ options={options.map((option) => ({ value: option, label: String(option) }))}
119
+ placeholder={placeholder}
120
+ {...inputProps}
121
+ />
122
+ </div>
123
+ );
124
+ }
125
+
126
+ if (type === 'object') {
127
+ const baseNode = findNode(ast, isDiscriminatedUnion);
128
+ const typeLiteral = baseNode
129
+ ? getDiscriminatedType(baseNode, inputProps.getValue() as any)
130
+ : findNode(ast, AST.isTypeLiteral);
131
+
132
+ if (typeLiteral) {
133
+ return (
134
+ <div role='none'>
135
+ {!inline && <div>{label}</div>}
136
+ <FormFields schema={S.make(typeLiteral)} path={path} readonly={readonly} Custom={Custom} />
137
+ </div>
138
+ );
139
+ }
140
+ }
141
+
142
+ return null;
143
+ };
144
+
145
+ export const FormFields = ({ schema, path, filter, sort, readonly, lookupComponent, Custom }: FormContentProps) => {
146
+ const values = useFormValues(path);
147
+
148
+ const properties = useMemo(() => {
149
+ const props = getSchemaProperties(schema.ast, values);
150
+ const filtered = filter ? filter(props) : props;
151
+
152
+ return sort ? filtered.sort((a, b) => sort.indexOf(a.name) - sort.indexOf(b.name)) : filtered;
153
+ }, [schema, values, filter, sort]);
154
+
155
+ return (
156
+ <div role='form' className={mx('flex flex-col w-full gap-2 py-2')}>
157
+ {properties
158
+ .map((property) => {
159
+ return (
160
+ <FormField
161
+ key={property.name}
162
+ property={property}
163
+ path={[...(path ?? []), property.name]}
164
+ readonly={readonly}
165
+ lookupComponent={lookupComponent}
166
+ Custom={Custom}
167
+ />
168
+ );
169
+ })
170
+ .filter(isNotFalsy)}
171
+ </div>
172
+ );
173
+ };
@@ -0,0 +1,48 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { createContext, useContext, type FocusEvent, type PropsWithChildren } from 'react';
6
+
7
+ import { raise } from '@dxos/debug';
8
+ import { type BaseObject, getValue } from '@dxos/echo-schema';
9
+ import { createJsonPath, type SimpleType } from '@dxos/effect';
10
+
11
+ import { type FormHandler, type FormOptions, useForm } from '../../hooks';
12
+
13
+ type FormContextValue<T extends BaseObject> = FormHandler<T>;
14
+
15
+ const FormContext = createContext<FormContextValue<any> | undefined>(undefined);
16
+
17
+ export const useFormContext = <T extends BaseObject>(): FormContextValue<T> => {
18
+ return useContext(FormContext) ?? raise(new Error('Missing FormContext'));
19
+ };
20
+
21
+ export const useFormValues = (path: (string | number)[] = []): any => {
22
+ const { values: formValues } = useFormContext();
23
+ const jsonPath = createJsonPath(path);
24
+ return getValue(formValues, jsonPath) as BaseObject;
25
+ };
26
+
27
+ export type FormInputStateProps = {
28
+ getStatus: () => { status?: 'error'; error?: string };
29
+ getValue: <V>() => V | undefined;
30
+ onValueChange: (type: SimpleType, value: any) => void;
31
+ onBlur: (event: FocusEvent<HTMLElement>) => void;
32
+ };
33
+
34
+ export const useInputProps = (path: (string | number)[] = []): FormInputStateProps => {
35
+ const { getStatus, getValue: getFormValue, onValueChange, onTouched } = useFormContext();
36
+
37
+ return {
38
+ getStatus: () => getStatus(path),
39
+ getValue: () => getFormValue(path),
40
+ onValueChange: (type: SimpleType, value: any) => onValueChange(path, type, value),
41
+ onBlur: () => onTouched(path),
42
+ };
43
+ };
44
+
45
+ export const FormProvider = ({ children, ...formOptions }: PropsWithChildren<FormOptions<any>>) => {
46
+ const formHandler = useForm(formOptions);
47
+ return <FormContext.Provider value={formHandler}>{children}</FormContext.Provider>;
48
+ };