@dxos/react-ui-form 0.8.4-main.2e9d522 → 0.8.4-main.3c1ae3b

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 (197) hide show
  1. package/dist/lib/browser/index.mjs +2138 -1738
  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 +2138 -1738
  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 +84 -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 +5 -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 +82 -5
  14. package/dist/types/src/components/FieldEditor/FieldEditor.stories.d.ts.map +1 -1
  15. package/dist/types/src/components/Form/Form-old.stories.d.ts +172 -0
  16. package/dist/types/src/components/Form/Form-old.stories.d.ts.map +1 -0
  17. package/dist/types/src/components/Form/Form.d.ts +78 -25
  18. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  19. package/dist/types/src/components/Form/Form.stories.d.ts +123 -51
  20. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  21. package/dist/types/src/components/Form/FormField.d.ts +37 -0
  22. package/dist/types/src/components/Form/FormField.d.ts.map +1 -0
  23. package/dist/types/src/components/Form/FormFieldComponent.d.ts +78 -0
  24. package/dist/types/src/components/Form/FormFieldComponent.d.ts.map +1 -0
  25. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts +9 -0
  26. package/dist/types/src/components/Form/FormFieldComponent.stories.d.ts.map +1 -0
  27. package/dist/types/src/components/Form/FormFieldSet.d.ts +19 -0
  28. package/dist/types/src/components/Form/FormFieldSet.d.ts.map +1 -0
  29. package/dist/types/src/components/Form/fields/ArrayField.d.ts +18 -0
  30. package/dist/types/src/components/Form/fields/ArrayField.d.ts.map +1 -0
  31. package/dist/types/src/components/Form/fields/BooleanField.d.ts +4 -0
  32. package/dist/types/src/components/Form/fields/BooleanField.d.ts.map +1 -0
  33. package/dist/types/src/components/Form/fields/GeoPointField.d.ts +5 -0
  34. package/dist/types/src/components/Form/fields/GeoPointField.d.ts.map +1 -0
  35. package/dist/types/src/components/Form/fields/MarkdownField.d.ts +4 -0
  36. package/dist/types/src/components/Form/fields/MarkdownField.d.ts.map +1 -0
  37. package/dist/types/src/components/Form/fields/NumberField.d.ts +4 -0
  38. package/dist/types/src/components/Form/fields/NumberField.d.ts.map +1 -0
  39. package/dist/types/src/components/Form/fields/RefField.d.ts +18 -0
  40. package/dist/types/src/components/Form/fields/RefField.d.ts.map +1 -0
  41. package/dist/types/src/components/Form/fields/SelectField.d.ts +10 -0
  42. package/dist/types/src/components/Form/fields/SelectField.d.ts.map +1 -0
  43. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts +5 -0
  44. package/dist/types/src/components/Form/fields/SelectOptionField.d.ts.map +1 -0
  45. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts +82 -0
  46. package/dist/types/src/components/Form/fields/SelectOptionField.stories.d.ts.map +1 -0
  47. package/dist/types/src/components/Form/fields/TextAreaField.d.ts +4 -0
  48. package/dist/types/src/components/Form/fields/TextAreaField.d.ts.map +1 -0
  49. package/dist/types/src/components/Form/fields/TextField.d.ts +4 -0
  50. package/dist/types/src/components/Form/fields/TextField.d.ts.map +1 -0
  51. package/dist/types/src/components/Form/fields/TupleField.d.ts +6 -0
  52. package/dist/types/src/components/Form/fields/TupleField.d.ts.map +1 -0
  53. package/dist/types/src/components/Form/fields/index.d.ts +12 -0
  54. package/dist/types/src/components/Form/fields/index.d.ts.map +1 -0
  55. package/dist/types/src/components/Form/index.d.ts +2 -4
  56. package/dist/types/src/components/Form/index.d.ts.map +1 -1
  57. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts +41 -0
  58. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -0
  59. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts +85 -0
  60. package/dist/types/src/components/ObjectPicker/ObjectPicker.stories.d.ts.map +1 -0
  61. package/dist/types/src/components/ObjectPicker/index.d.ts +2 -0
  62. package/dist/types/src/components/ObjectPicker/index.d.ts.map +1 -0
  63. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts +21 -9
  64. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  65. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +83 -8
  66. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  67. package/dist/types/src/components/index.d.ts +1 -1
  68. package/dist/types/src/components/index.d.ts.map +1 -1
  69. package/dist/types/src/components/testing.d.ts +4 -3
  70. package/dist/types/src/components/testing.d.ts.map +1 -1
  71. package/dist/types/src/hooks/index.d.ts +3 -2
  72. package/dist/types/src/hooks/index.d.ts.map +1 -1
  73. package/dist/types/src/hooks/useFormHandler.d.ts +82 -0
  74. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -0
  75. package/dist/types/src/hooks/useKeyHandler.d.ts +6 -0
  76. package/dist/types/src/hooks/useKeyHandler.d.ts.map +1 -0
  77. package/dist/types/src/hooks/useQueryRefOptions.d.ts +13 -6
  78. package/dist/types/src/hooks/useQueryRefOptions.d.ts.map +1 -1
  79. package/dist/types/src/hooks/useRefQueryOptions.d.ts +8 -0
  80. package/dist/types/src/hooks/useRefQueryOptions.d.ts.map +1 -0
  81. package/dist/types/src/translations.d.ts +17 -8
  82. package/dist/types/src/translations.d.ts.map +1 -1
  83. package/dist/types/src/util/field.d.ts +3 -3
  84. package/dist/types/src/util/field.d.ts.map +1 -1
  85. package/dist/types/src/util/formatting.d.ts +2 -2
  86. package/dist/types/src/util/formatting.d.ts.map +1 -1
  87. package/dist/types/src/util/index.d.ts +1 -1
  88. package/dist/types/src/util/index.d.ts.map +1 -1
  89. package/dist/types/src/util/properties.d.ts +7 -0
  90. package/dist/types/src/util/properties.d.ts.map +1 -0
  91. package/dist/types/src/util/refs.d.ts +2 -3
  92. package/dist/types/src/util/refs.d.ts.map +1 -1
  93. package/dist/types/tsconfig.tsbuildinfo +1 -1
  94. package/package.json +43 -36
  95. package/src/components/ControlSection/ControlSection.stories.tsx +11 -14
  96. package/src/components/ControlSection/ControlSection.tsx +26 -12
  97. package/src/components/FieldEditor/FieldEditor.stories.tsx +20 -20
  98. package/src/components/FieldEditor/FieldEditor.test.tsx +3 -2
  99. package/src/components/FieldEditor/FieldEditor.tsx +75 -77
  100. package/src/components/Form/Form-old.stories.tsx +314 -0
  101. package/src/components/Form/Form.stories.tsx +163 -219
  102. package/src/components/Form/Form.tsx +306 -98
  103. package/src/components/Form/FormField.tsx +280 -0
  104. package/src/components/Form/FormFieldComponent.stories.tsx +65 -0
  105. package/src/components/Form/FormFieldComponent.tsx +173 -0
  106. package/src/components/Form/FormFieldSet.tsx +124 -0
  107. package/src/components/Form/README.md +3 -0
  108. package/src/components/Form/fields/ArrayField.tsx +154 -0
  109. package/src/components/Form/fields/BooleanField.tsx +22 -0
  110. package/src/components/Form/fields/GeoPointField.tsx +120 -0
  111. package/src/components/Form/fields/MarkdownField.tsx +78 -0
  112. package/src/components/Form/fields/NumberField.tsx +39 -0
  113. package/src/components/Form/fields/RefField.tsx +206 -0
  114. package/src/components/Form/fields/SelectField.tsx +65 -0
  115. package/src/components/Form/{custom/SelectOptionsInput.stories.tsx → fields/SelectOptionField.stories.tsx} +25 -22
  116. package/src/components/Form/{custom/SelectOptionsInput.tsx → fields/SelectOptionField.tsx} +47 -42
  117. package/src/components/Form/fields/TextAreaField.tsx +38 -0
  118. package/src/components/Form/fields/TextField.tsx +38 -0
  119. package/src/components/Form/{custom/TupleInput.tsx → fields/TupleField.tsx} +15 -8
  120. package/src/components/Form/fields/index.ts +15 -0
  121. package/src/components/Form/index.ts +3 -4
  122. package/src/components/ObjectPicker/ObjectPicker.stories.tsx +207 -0
  123. package/src/components/ObjectPicker/ObjectPicker.tsx +166 -0
  124. package/src/components/ObjectPicker/index.ts +5 -0
  125. package/src/components/ViewEditor/ViewEditor.stories.tsx +131 -61
  126. package/src/components/ViewEditor/ViewEditor.test.tsx +19 -17
  127. package/src/components/ViewEditor/ViewEditor.tsx +277 -197
  128. package/src/components/index.ts +1 -2
  129. package/src/components/testing.tsx +20 -10
  130. package/src/hooks/index.ts +3 -2
  131. package/src/hooks/useFormHandler.ts +348 -0
  132. package/src/hooks/useKeyHandler.tsx +37 -0
  133. package/src/hooks/useQueryRefOptions.ts +49 -32
  134. package/src/hooks/useRefQueryOptions.ts +37 -0
  135. package/src/translations.ts +20 -11
  136. package/src/util/field.ts +33 -43
  137. package/src/util/formatting.ts +22 -22
  138. package/src/util/index.ts +1 -1
  139. package/src/util/properties.ts +18 -0
  140. package/src/util/refs.ts +12 -18
  141. package/dist/types/src/components/Form/ArrayField.d.ts +0 -16
  142. package/dist/types/src/components/Form/ArrayField.d.ts.map +0 -1
  143. package/dist/types/src/components/Form/Defaults.d.ts +0 -14
  144. package/dist/types/src/components/Form/Defaults.d.ts.map +0 -1
  145. package/dist/types/src/components/Form/FormActions.d.ts +0 -9
  146. package/dist/types/src/components/Form/FormActions.d.ts.map +0 -1
  147. package/dist/types/src/components/Form/FormContent.d.ts +0 -57
  148. package/dist/types/src/components/Form/FormContent.d.ts.map +0 -1
  149. package/dist/types/src/components/Form/FormContext.d.ts +0 -23
  150. package/dist/types/src/components/Form/FormContext.d.ts.map +0 -1
  151. package/dist/types/src/components/Form/Input.d.ts +0 -26
  152. package/dist/types/src/components/Form/Input.d.ts.map +0 -1
  153. package/dist/types/src/components/Form/RefField.d.ts +0 -12
  154. package/dist/types/src/components/Form/RefField.d.ts.map +0 -1
  155. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts +0 -4
  156. package/dist/types/src/components/Form/custom/GeoPointInput.d.ts.map +0 -1
  157. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts +0 -4
  158. package/dist/types/src/components/Form/custom/SelectOptionsInput.d.ts.map +0 -1
  159. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts +0 -7
  160. package/dist/types/src/components/Form/custom/SelectOptionsInput.stories.d.ts.map +0 -1
  161. package/dist/types/src/components/Form/custom/TupleInput.d.ts +0 -6
  162. package/dist/types/src/components/Form/custom/TupleInput.d.ts.map +0 -1
  163. package/dist/types/src/components/Form/custom/index.d.ts +0 -4
  164. package/dist/types/src/components/Form/custom/index.d.ts.map +0 -1
  165. package/dist/types/src/components/Form/factory.d.ts +0 -8
  166. package/dist/types/src/components/Form/factory.d.ts.map +0 -1
  167. package/dist/types/src/components/deprecated/Form.d.ts +0 -15
  168. package/dist/types/src/components/deprecated/Form.d.ts.map +0 -1
  169. package/dist/types/src/components/deprecated/Form.stories.d.ts +0 -13
  170. package/dist/types/src/components/deprecated/Form.stories.d.ts.map +0 -1
  171. package/dist/types/src/components/deprecated/FormInput.d.ts +0 -16
  172. package/dist/types/src/components/deprecated/FormInput.d.ts.map +0 -1
  173. package/dist/types/src/components/deprecated/index.d.ts +0 -2
  174. package/dist/types/src/components/deprecated/index.d.ts.map +0 -1
  175. package/dist/types/src/hooks/useForm.d.ts +0 -67
  176. package/dist/types/src/hooks/useForm.d.ts.map +0 -1
  177. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts +0 -8
  178. package/dist/types/src/hooks/useRefQueryLookupHandler.d.ts.map +0 -1
  179. package/dist/types/src/util/schema.d.ts +0 -3
  180. package/dist/types/src/util/schema.d.ts.map +0 -1
  181. package/src/components/Form/ArrayField.tsx +0 -124
  182. package/src/components/Form/Defaults.tsx +0 -203
  183. package/src/components/Form/FormActions.tsx +0 -53
  184. package/src/components/Form/FormContent.tsx +0 -240
  185. package/src/components/Form/FormContext.tsx +0 -110
  186. package/src/components/Form/Input.tsx +0 -51
  187. package/src/components/Form/RefField.tsx +0 -213
  188. package/src/components/Form/custom/GeoPointInput.tsx +0 -72
  189. package/src/components/Form/custom/index.ts +0 -7
  190. package/src/components/Form/factory.tsx +0 -31
  191. package/src/components/deprecated/Form.stories.tsx +0 -62
  192. package/src/components/deprecated/Form.tsx +0 -119
  193. package/src/components/deprecated/FormInput.tsx +0 -71
  194. package/src/components/deprecated/index.ts +0 -5
  195. package/src/hooks/useForm.ts +0 -291
  196. package/src/hooks/useRefQueryLookupHandler.ts +0 -39
  197. package/src/util/schema.ts +0 -14
@@ -1,108 +1,316 @@
1
1
  //
2
- // Copyright 2024 DXOS.org
3
- //
4
-
5
- import { type Schema } from 'effect';
6
- import React, { type ReactElement, useEffect, useMemo, useRef } from 'react';
7
-
8
- import { type BaseObject, type PropertyKey } from '@dxos/echo-schema';
9
- import { type ThemedClassName } from '@dxos/react-ui';
10
- import { cardDialogOverflow, cardSpacing } from '@dxos/react-ui-stack';
11
- import { type SchemaProperty } from '@dxos/schema';
12
-
13
- import { FormActions, type FormOuterSpacing } from './FormActions';
14
- import { FormFields, type FormFieldsProps } from './FormContent';
15
- import { FormProvider } from './FormContext';
16
- import { type InputProps, type InputComponent } from './Input';
17
- import { type FormOptions } from '../../hooks';
18
-
19
- export type PropsFilter<T extends BaseObject> = (props: SchemaProperty<T>[]) => SchemaProperty<T>[];
20
-
21
- export type ComponentLookup = (args: {
22
- prop: string;
23
- schema: Schema.Schema<any>;
24
- inputProps: InputProps;
25
- }) => ReactElement | undefined;
26
-
27
- export type CustomInputMap = Partial<Record<string, InputComponent>>;
28
-
29
- export type FormProps<T extends BaseObject> = ThemedClassName<{
30
- values: Partial<T>;
31
- // TODO(burdon): Autofocus first input.
32
- autoFocus?: boolean;
33
- // TODO(burdon): Change to JsonPath includes/excludes.
34
- filter?: PropsFilter<T>;
35
- sort?: PropertyKey<T>[];
36
- autoSave?: boolean;
37
- outerSpacing?: FormOuterSpacing;
38
- onCancel?: () => void;
39
- }> &
40
- Pick<FormOptions<T>, 'schema' | 'onValuesChanged' | 'onValidate' | 'onSave'> &
41
- FormFieldsProps;
42
-
43
- export const Form = <T extends BaseObject>({
44
- classNames,
45
- testId,
46
- values: initialValues,
47
- autoFocus,
48
- readonly,
49
- autoSave,
50
- outerSpacing = true,
51
- onCancel,
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { createContext } from '@radix-ui/react-context';
6
+ import * as Schema from 'effect/Schema';
7
+ import type * as SchemaAST from 'effect/SchemaAST';
8
+ import React, { type PropsWithChildren, useMemo, useRef } from 'react';
9
+
10
+ import { type AnyProperties } from '@dxos/echo/internal';
11
+ import { createJsonPath, getValue as getValue$ } from '@dxos/effect';
12
+ import { IconButton, type IconButtonProps, ScrollArea, type ThemedClassName, useTranslation } from '@dxos/react-ui';
13
+ import { mx } from '@dxos/react-ui-theme';
14
+
15
+ import {
16
+ type FormHandler,
17
+ type FormHandlerProps,
18
+ type FormUpdateMeta,
19
+ useFormHandler,
20
+ useKeyHandler,
21
+ } from '../../hooks';
22
+ import { translationKey } from '../../translations';
23
+
24
+ import { FormFieldLabel, type FormFieldLabelProps, type FormFieldStateProps } from './FormFieldComponent';
25
+ import {
26
+ FormFieldSet as NaturalFormFieldSet,
27
+ type FormFieldSetProps as NaturalFormFieldSetProps,
28
+ } from './FormFieldSet';
29
+
30
+ // New features/polish
31
+ // [x] Unify readonly/inline modes
32
+ // [ ] Don't call save/autoSave if value hasn't changed
33
+ // [ ] Fix onCancel (restore values)
34
+ // [ ] Fix useSchema Type.Obj.Any cast
35
+ // [ ] Remove @dxos/echo-db deps
36
+ // [ ] TableCellEditor (handleEnter/ModalController).
37
+ // [ ] Use FormFieldWrapper uniformly
38
+ // [ ] Inline tables for object arrays
39
+ // [ ] Defer query until popover
40
+ // [ ] Omit id from sub properties.
41
+ // [x] Refs
42
+ // [x] Single-select (fix popover)
43
+ // [x] Multi-select (array)
44
+ // [ ] auto save doesn't work for combobox + select due to only firing on blur (workaround is to use onValuesChanged)
45
+
46
+ // TODO(burdon): Move to @dxos/schema (re-export here).
47
+ export type ExcludeId<S extends Schema.Schema.AnyNoContext> = Omit<Schema.Schema.Type<S>, 'id'>;
48
+
49
+ // TODO(burdon): Move to @dxos/schema (re-export here).
50
+ export const omitId = <S extends Schema.Schema.AnyNoContext>(schema: S): Schema.Schema<ExcludeId<S>, ExcludeId<S>> =>
51
+ schema.pipe(Schema.omit('id')) as any;
52
+
53
+ //
54
+ // Context
55
+ //
56
+
57
+ type FormContextValue<T extends AnyProperties = any> = {
58
+ /**
59
+ * Form handler.
60
+ */
61
+ form: FormHandler<T>;
62
+
63
+ /**
64
+ * Show debug info.
65
+ */
66
+ debug?: boolean;
67
+
68
+ /**
69
+ * Testing.
70
+ */
71
+ testId?: string;
72
+ } & Pick<NaturalFormFieldSetProps<T>, 'readonly' | 'layout' | 'fieldMap' | 'fieldProvider'>;
73
+
74
+ const [FormContextProvider, useFormContext] = createContext<FormContextValue>('Form');
75
+
76
+ /**
77
+ * Get the current form values.
78
+ */
79
+ const useFormValues = <T extends AnyProperties>(componentName: string, path: (string | number)[] = []): T => {
80
+ const jsonPath = createJsonPath(path);
81
+ const {
82
+ form: { values },
83
+ } = useFormContext(componentName);
84
+
85
+ return getValue$(values, jsonPath) as T;
86
+ };
87
+
88
+ /**
89
+ * Get the state props for the given field.
90
+ */
91
+ const useFormFieldState = (componentName: string, path: (string | number)[] = []): FormFieldStateProps => {
92
+ const stablePath = useMemo(() => path, [Array.isArray(path) ? path.join('.') : path]);
93
+ const {
94
+ form: { getStatus, getValue, onBlur, onValueChange },
95
+ } = useFormContext(componentName);
96
+
97
+ return useMemo(
98
+ () => ({
99
+ getStatus: () => getStatus(stablePath),
100
+ getValue: () => getValue(stablePath),
101
+ onBlur: () => onBlur(stablePath),
102
+ onValueChange: (ast: SchemaAST.AST, value: any) => onValueChange(stablePath, ast, value),
103
+ }),
104
+ [getStatus, getValue, onBlur, onValueChange, stablePath],
105
+ );
106
+ };
107
+
108
+ //
109
+ // Root
110
+ //
111
+
112
+ type FormRootProps<T extends AnyProperties = AnyProperties> = PropsWithChildren<
113
+ {
114
+ /**
115
+ * Called when the form is submitted and passes validation.
116
+ */
117
+ onSave?: (values: T, meta: FormUpdateMeta<T>) => void;
118
+
119
+ /**
120
+ * Called when the form is canceled to abandon/undo any pending changes.
121
+ */
122
+ onCancel?: () => void;
123
+ } & Omit<FormContextValue<T>, 'form'> &
124
+ Pick<FormHandlerProps<T>, 'schema' | 'autoSave' | 'values' | 'defaultValues' | 'onValidate' | 'onValuesChanged'> &
125
+ Omit<NaturalFormFieldSetProps<T>, 'schema' | 'path'>
126
+ >;
127
+
128
+ const FormRoot = <T extends AnyProperties = AnyProperties>({
129
+ children,
52
130
  schema,
53
- onValuesChanged,
54
- onValidate,
131
+ values,
55
132
  onSave,
133
+ onCancel,
56
134
  ...props
57
- }: FormProps<T>) => {
58
- const formRef = useRef<HTMLDivElement>(null);
59
-
60
- // TODO(burdon): Rename.
61
- const handleValid = useMemo(() => (autoSave ? onSave : undefined), [autoSave, onSave]);
62
-
63
- // Focus the first input element within this form.
64
- useEffect(() => {
65
- if (autoFocus && formRef.current) {
66
- const input = formRef.current.querySelector('input');
67
- if (input) {
68
- input.focus();
69
- }
70
- }
71
- }, [autoFocus]);
135
+ }: FormRootProps<T>) => {
136
+ const form = useFormHandler({ schema, values, onSave, onCancel, ...props });
137
+
138
+ return (
139
+ <FormContextProvider form={form} {...props}>
140
+ {children}
141
+ </FormContextProvider>
142
+ );
143
+ };
144
+
145
+ FormRoot.displayName = 'Form.Root';
146
+
147
+ //
148
+ // Viewport
149
+ //
150
+
151
+ type FormViewportProps = ThemedClassName<PropsWithChildren<{}>>;
152
+
153
+ const FormViewport = ({ classNames, children }: FormViewportProps) => {
154
+ return (
155
+ <ScrollArea.Root>
156
+ <ScrollArea.Viewport classNames={['plb-cardSpacingBlock', classNames]}>{children}</ScrollArea.Viewport>
157
+ <ScrollArea.Scrollbar orientation='vertical'>
158
+ <ScrollArea.Thumb />
159
+ </ScrollArea.Scrollbar>
160
+ </ScrollArea.Root>
161
+ );
162
+ };
163
+
164
+ FormViewport.displayName = 'Form.Viewport';
165
+
166
+ //
167
+ // Content
168
+ //
169
+
170
+ type FormContentProps = ThemedClassName<PropsWithChildren<{}>>;
171
+
172
+ // TOOD(burdon): Figure out nesting (indent and testId).
173
+ const FormContent = ({ classNames, children }: FormContentProps) => {
174
+ const { form, testId } = useFormContext(FormContent.displayName);
175
+ const ref = useRef<HTMLDivElement>(null);
176
+ useKeyHandler(ref.current, form);
72
177
 
73
178
  return (
74
- <FormProvider
75
- formRef={formRef}
76
- schema={schema}
77
- autoSave={autoSave}
78
- initialValues={initialValues}
79
- onValuesChanged={onValuesChanged}
80
- onValidate={onValidate}
81
- onValid={handleValid}
82
- onSave={onSave}
179
+ <div
180
+ ref={ref}
181
+ role='form'
182
+ className={mx('flex flex-col is-full pli-cardSpacingInline', classNames)}
183
+ data-testid={testId}
83
184
  >
84
- <div role='none' className='contents' data-testid={testId}>
85
- <FormFields
86
- {...props}
87
- ref={formRef}
88
- classNames={[
89
- outerSpacing === 'blockStart-0'
90
- ? 'pli-cardSpacingInline mbe-cardSpacingBlock [&>.mbs-inputSpacingBlock:first-child]:!mbs-0'
91
- : outerSpacing === 'scroll-fields'
92
- ? 'pli-cardSpacingInline pbe-cardSpacingBlock'
93
- : outerSpacing
94
- ? cardSpacing
95
- : false,
96
- outerSpacing === 'scroll-fields' && cardDialogOverflow,
97
- classNames,
98
- ]}
99
- readonly={readonly}
100
- schema={schema}
185
+ {children}
186
+ </div>
187
+ );
188
+ };
189
+
190
+ FormContent.displayName = 'Form.Content';
191
+
192
+ //
193
+ // FieldSet
194
+ //
195
+
196
+ type FormFieldSetProps = ThemedClassName<{}>;
197
+
198
+ const FormFieldSet = ({ classNames }: FormFieldSetProps) => {
199
+ const { form, ...props } = useFormContext(FormFieldSet.displayName);
200
+
201
+ return <NaturalFormFieldSet classNames={classNames} schema={form.schema} {...props} />;
202
+ };
203
+
204
+ FormFieldSet.displayName = 'Form.FieldSet';
205
+
206
+ //
207
+ // Actions
208
+ //
209
+
210
+ type FormActionsProps = ThemedClassName<{}>;
211
+
212
+ const FormActions = ({ classNames }: FormActionsProps) => {
213
+ const { t } = useTranslation(translationKey);
214
+ const {
215
+ form: { isValid, onSave, onCancel },
216
+ readonly,
217
+ layout,
218
+ } = useFormContext(FormActions.displayName);
219
+
220
+ if (readonly || layout === 'static') {
221
+ return null;
222
+ }
223
+
224
+ // TODO(burdon): Currently onCancel is a no-op; implement "revert values".
225
+ // Deprecate FormSubmit ans use FormActions without Cancel button if no callback is supplied.
226
+
227
+ return (
228
+ <div role='none' className={mx('grid grid-flow-col auto-cols-fr gap-2 pbs-cardSpacingBlock', classNames)}>
229
+ {onCancel && (
230
+ <IconButton
231
+ icon='ph--x--regular'
232
+ iconEnd
233
+ label={t('cancel button label')}
234
+ onClick={onCancel}
235
+ data-testid='cancel-button'
236
+ />
237
+ )}
238
+ {onSave && (
239
+ <IconButton
240
+ type='submit'
241
+ variant='primary'
242
+ disabled={!isValid}
243
+ icon='ph--check--regular'
244
+ iconEnd
245
+ label={t('save button label')}
246
+ onClick={onSave}
247
+ data-testid='save-button'
101
248
  />
102
- {(onCancel || onSave) && !autoSave && (
103
- <FormActions readonly={readonly} onCancel={onCancel} outerSpacing={outerSpacing} />
104
- )}
105
- </div>
106
- </FormProvider>
249
+ )}
250
+ </div>
251
+ );
252
+ };
253
+
254
+ FormActions.displayName = 'Form.Actions';
255
+
256
+ //
257
+ // Submit
258
+ //
259
+
260
+ type FormSubmitProps = ThemedClassName<Partial<Pick<IconButtonProps, 'icon' | 'label'>>>;
261
+
262
+ const FormSubmit = ({ classNames, label, icon }: FormSubmitProps) => {
263
+ const { t } = useTranslation(translationKey);
264
+ const {
265
+ form: { isValid, onSave },
266
+ readonly,
267
+ layout,
268
+ } = useFormContext(FormSubmit.displayName);
269
+
270
+ if (readonly || layout === 'static') {
271
+ return null;
272
+ }
273
+
274
+ return (
275
+ <div role='none' className={mx('flex is-full pbs-cardSpacingBlock', classNames)}>
276
+ <IconButton
277
+ classNames='is-full'
278
+ type='submit'
279
+ variant='primary'
280
+ disabled={!isValid}
281
+ icon={icon ?? 'ph--check--regular'}
282
+ label={label ?? t('save button label')}
283
+ onClick={onSave}
284
+ data-testid='save-button'
285
+ />
286
+ </div>
107
287
  );
108
288
  };
289
+
290
+ FormSubmit.displayName = 'Form.Submit';
291
+
292
+ //
293
+ // Form
294
+ // https://www.radix-ui.com/primitives/docs/guides/composition
295
+ //
296
+
297
+ export const Form = {
298
+ Root: FormRoot,
299
+ Viewport: FormViewport,
300
+ Content: FormContent,
301
+ FieldSet: FormFieldSet,
302
+ Actions: FormActions,
303
+ Submit: FormSubmit,
304
+ Label: FormFieldLabel,
305
+ };
306
+
307
+ export { useFormContext, useFormValues, useFormFieldState };
308
+
309
+ export type {
310
+ FormRootProps,
311
+ FormViewportProps,
312
+ FormContentProps,
313
+ FormFieldSetProps,
314
+ FormActionsProps,
315
+ FormFieldLabelProps as LabelProps,
316
+ };
@@ -0,0 +1,280 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Match from 'effect/Match';
6
+ import * as Option from 'effect/Option';
7
+ import * as Schema from 'effect/Schema';
8
+ import * as SchemaAST from 'effect/SchemaAST';
9
+ import * as String from 'effect/String';
10
+ import React, { useMemo } from 'react';
11
+
12
+ import { Format } from '@dxos/echo';
13
+ import {
14
+ createJsonPath,
15
+ findNode,
16
+ getAnnotation,
17
+ getDiscriminatedType,
18
+ isArrayType,
19
+ isDiscriminatedUnion,
20
+ isLiteralUnion,
21
+ isNestedType,
22
+ } from '@dxos/effect';
23
+ import { useTranslation } from '@dxos/react-ui';
24
+ import { type ProjectionModel } from '@dxos/schema';
25
+
26
+ import { translationKey } from '../../translations';
27
+ import { getRefProps } from '../../util';
28
+
29
+ import {
30
+ ArrayField,
31
+ BooleanField,
32
+ GeoPointField,
33
+ MarkdownField,
34
+ NumberField,
35
+ RefField,
36
+ type RefFieldProps,
37
+ SelectField,
38
+ TextAreaField,
39
+ TextField,
40
+ } from './fields';
41
+ import { useFormFieldState } from './Form';
42
+ import {
43
+ type FormFieldComponent,
44
+ type FormFieldComponentProps,
45
+ type FormFieldMap,
46
+ type FormFieldProvider,
47
+ } from './FormFieldComponent';
48
+ import { FormFieldSet } from './FormFieldSet';
49
+
50
+ export type FormFieldProps = {
51
+ /**
52
+ * AST of the property to render.
53
+ */
54
+ type: SchemaAST.AST;
55
+
56
+ /**
57
+ * Name of the property.
58
+ */
59
+ name: string;
60
+
61
+ /**
62
+ * Path to the current object from the root. Used with nested forms.
63
+ */
64
+ path?: (string | number)[];
65
+
66
+ /**
67
+ * Optional projection for projection-based field management.
68
+ */
69
+ projection?: ProjectionModel;
70
+
71
+ /**
72
+ * Map of custom renderers for specific properties.
73
+ * Prefer fieldProvider for plugin specific input surfaces.
74
+ */
75
+ fieldMap?: FormFieldMap;
76
+
77
+ /**
78
+ * Function to lookup custom renderers for specific properties.
79
+ */
80
+ fieldProvider?: FormFieldProvider;
81
+ } & Pick<
82
+ RefFieldProps,
83
+ | 'autoFocus'
84
+ | 'readonly'
85
+ | 'layout'
86
+ | 'createSchema'
87
+ | 'createOptionLabel'
88
+ | 'createOptionIcon'
89
+ | 'createInitialValuePath'
90
+ | 'onCreate'
91
+ | 'onQueryRefOptions'
92
+ >;
93
+
94
+ export const FormField = (props: FormFieldProps) => {
95
+ const {
96
+ type,
97
+ name,
98
+ path,
99
+ projection,
100
+ fieldMap,
101
+ fieldProvider,
102
+ readonly,
103
+ layout,
104
+ createSchema,
105
+ createOptionLabel,
106
+ createOptionIcon,
107
+ createInitialValuePath,
108
+ onCreate,
109
+ onQueryRefOptions,
110
+ } = props;
111
+ const { t } = useTranslation(translationKey);
112
+ const title = getAnnotation<string>(SchemaAST.TitleAnnotationId)(type);
113
+ const description = getAnnotation<string>(SchemaAST.DescriptionAnnotationId)(type);
114
+ const examples = getAnnotation<string[]>(SchemaAST.ExamplesAnnotationId)(type);
115
+
116
+ const label = useMemo(() => title ?? String.capitalize(name), [title, name]);
117
+ const placeholder = useMemo(
118
+ () => (examples?.length ? `${t('example placeholder')}: ${examples[0]}` : (description ?? label)),
119
+ [examples, description, label],
120
+ );
121
+
122
+ const fieldState = useFormFieldState(FormField.displayName, path);
123
+ const fieldProps: FormFieldComponentProps = {
124
+ type,
125
+ format: Format.FormatAnnotation.getFromAst(type).pipe((annotation) => Option.getOrUndefined(annotation)),
126
+ readonly,
127
+ label,
128
+ placeholder,
129
+ layout,
130
+ ...fieldState,
131
+ };
132
+
133
+ //
134
+ // Custom field.
135
+ //
136
+
137
+ const jsonPath = createJsonPath(path ?? []);
138
+ const CustomField = fieldMap?.[jsonPath];
139
+ if (CustomField) {
140
+ return <CustomField {...fieldProps} />;
141
+ }
142
+
143
+ // TODO(burdon): Expensive to create schema each time; pass AST?
144
+ const component = fieldProvider?.({ schema: Schema.make(type), prop: name, fieldProps });
145
+ if (component) {
146
+ return component;
147
+ }
148
+
149
+ //
150
+ // Array field.
151
+ //
152
+
153
+ if (isArrayType(type)) {
154
+ return <ArrayField fieldProps={fieldState} label={label} {...props} />;
155
+ }
156
+
157
+ //
158
+ // Regular field.
159
+ //
160
+
161
+ const Field = getFormField(fieldProps);
162
+ if (Field) {
163
+ return <Field {...fieldProps} />;
164
+ }
165
+
166
+ //
167
+ // Select field.
168
+ //
169
+
170
+ const options = getOptions(type);
171
+ if (options) {
172
+ return (
173
+ <SelectField
174
+ {...fieldProps}
175
+ options={options.map((option) => ({
176
+ value: option,
177
+ label: option.toString(),
178
+ }))}
179
+ />
180
+ );
181
+ }
182
+
183
+ //
184
+ // Ref field.
185
+ //
186
+
187
+ const refProps = getRefProps(type);
188
+ if (refProps) {
189
+ return (
190
+ <RefField
191
+ {...fieldProps}
192
+ {...refProps}
193
+ createSchema={createSchema}
194
+ createOptionLabel={createOptionLabel}
195
+ createOptionIcon={createOptionIcon}
196
+ createInitialValuePath={createInitialValuePath}
197
+ onCreate={onCreate}
198
+ onQueryRefOptions={onQueryRefOptions}
199
+ />
200
+ );
201
+ }
202
+
203
+ //
204
+ // Nested Object field.
205
+ //
206
+
207
+ if (isNestedType(type)) {
208
+ const baseNode = findNode(type, isDiscriminatedUnion);
209
+ const typeLiteral = baseNode
210
+ ? getDiscriminatedType(baseNode, fieldState.getValue() as any)
211
+ : findNode(type, SchemaAST.isTypeLiteral);
212
+
213
+ if (typeLiteral) {
214
+ const schema = Schema.make(typeLiteral);
215
+ return (
216
+ <FormFieldSet
217
+ schema={schema}
218
+ path={path}
219
+ readonly={readonly}
220
+ layout={layout}
221
+ label={label}
222
+ projection={projection}
223
+ fieldMap={fieldMap}
224
+ fieldProvider={fieldProvider}
225
+ createOptionLabel={createOptionLabel}
226
+ createOptionIcon={createOptionIcon}
227
+ onCreate={onCreate}
228
+ onQueryRefOptions={onQueryRefOptions}
229
+ />
230
+ );
231
+ }
232
+ }
233
+
234
+ return null;
235
+ };
236
+
237
+ FormField.displayName = 'Form.FormField';
238
+
239
+ /**
240
+ * Get property input component.
241
+ */
242
+ const getFormField = ({ type, format }: FormFieldComponentProps): FormFieldComponent | undefined => {
243
+ //
244
+ // Standard formats.
245
+ //
246
+
247
+ const formatField = Match.value(format).pipe(
248
+ Match.withReturnType<FormFieldComponent | undefined>(),
249
+ Match.when(Format.TypeFormat.GeoPoint, () => GeoPointField),
250
+ Match.when(Format.TypeFormat.Markdown, () => MarkdownField),
251
+ Match.when(Format.TypeFormat.Text, () => TextAreaField),
252
+ Match.orElse(() => undefined),
253
+ );
254
+ if (formatField) {
255
+ return formatField;
256
+ }
257
+
258
+ //
259
+ // Standard types.
260
+ //
261
+
262
+ switch (type._tag) {
263
+ // TODO(wittjosiah): Schema.Any is currently used to represent template inputs.
264
+ case 'AnyKeyword':
265
+ case 'StringKeyword':
266
+ return TextField;
267
+ case 'NumberKeyword':
268
+ return NumberField;
269
+ case 'BooleanKeyword':
270
+ return BooleanField;
271
+ }
272
+ };
273
+
274
+ const getOptions = (ast: SchemaAST.AST): Format.Options[] | undefined => {
275
+ if (isLiteralUnion(ast)) {
276
+ return ast.types.map((type) => type.literal).filter((v): v is string | number => v !== null);
277
+ }
278
+
279
+ return Format.OptionsAnnotation.getFromAst(ast).pipe((annotation) => Option.getOrUndefined(annotation));
280
+ };