@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
@@ -0,0 +1,65 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import React, { type ChangeEvent, useCallback } from 'react';
7
+
8
+ import { Input } from '@dxos/react-ui';
9
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
10
+
11
+ import { type FormFieldComponentProps, FormFieldWrapper } from './FormFieldComponent';
12
+
13
+ const Component = ({
14
+ type,
15
+ placeholder,
16
+ readonly,
17
+ onBlur,
18
+ onValueChange,
19
+ ...props
20
+ }: FormFieldComponentProps<string>) => {
21
+ const handleChange = useCallback(
22
+ (event: ChangeEvent<HTMLInputElement>) => onValueChange(type, event.target.value),
23
+ [type, onValueChange],
24
+ );
25
+
26
+ return (
27
+ <div className='plb-cardSpacingBlock pli-cardSpacingInline'>
28
+ <FormFieldWrapper<string> {...props}>
29
+ {({ value = '' }) => (
30
+ <Input.TextInput
31
+ disabled={!!readonly}
32
+ placeholder={placeholder}
33
+ noAutoFill
34
+ value={value}
35
+ onChange={handleChange}
36
+ onBlur={onBlur}
37
+ />
38
+ )}
39
+ </FormFieldWrapper>
40
+ </div>
41
+ );
42
+ };
43
+
44
+ const meta: Meta<typeof Component> = {
45
+ title: 'ui/react-ui-form/FormFieldComponent',
46
+ component: Component,
47
+ decorators: [withTheme, withLayout({ container: 'column' })],
48
+ parameters: {
49
+ layout: 'fullscreen',
50
+ },
51
+ };
52
+
53
+ export default meta;
54
+
55
+ type Story = StoryObj<typeof meta>;
56
+
57
+ export const Default: Story = {
58
+ args: {
59
+ label: 'Label',
60
+ getStatus: () => ({ status: 'error', error: 'Required field' }),
61
+ getValue: () => 'DXOS',
62
+ onBlur: () => {},
63
+ onValueChange: () => {},
64
+ },
65
+ };
@@ -0,0 +1,173 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import type * as Schema from 'effect/Schema';
6
+ import type * as SchemaAST from 'effect/SchemaAST';
7
+ import React, {
8
+ Component,
9
+ type FC,
10
+ type FocusEvent,
11
+ type PropsWithChildren,
12
+ type ReactElement,
13
+ type ReactNode,
14
+ } from 'react';
15
+
16
+ import { type Format } from '@dxos/echo/internal';
17
+ import { Icon, Input, Tooltip } from '@dxos/react-ui';
18
+ import { labelSpacing } from '@dxos/react-ui-stack';
19
+ import { errorText, mx } from '@dxos/react-ui-theme';
20
+
21
+ import { type FormFieldStatus } from '../../hooks';
22
+
23
+ /**
24
+ * Dynamic props passed to input components.
25
+ */
26
+ export type FormFieldStateProps<T = any> = {
27
+ getStatus: () => FormFieldStatus;
28
+ getValue: () => T | undefined;
29
+ onBlur: (event: FocusEvent<HTMLElement>) => void;
30
+ onValueChange: (type: SchemaAST.AST, value: T) => void;
31
+ };
32
+
33
+ /**
34
+ * Presentation mode.
35
+ *
36
+ * - full: Show label, control, and status.
37
+ * - compact: Show label and control.
38
+ * - inline: Control only.
39
+ * - static: Plain DOM; omit all undefined values.
40
+ */
41
+ // TODO(burdon): 'inline' should be orthogonal to the layout (e.g., array of inline static).
42
+ export type Presentation = 'full' | 'compact' | 'inline' | 'static';
43
+
44
+ /**
45
+ * Props passed to input components.
46
+ */
47
+ export type FormFieldComponentProps<T = any> = {
48
+ type: SchemaAST.AST;
49
+ format?: Format.TypeFormat;
50
+ readonly?: boolean;
51
+ label: string;
52
+ placeholder?: string;
53
+ autoFocus?: boolean;
54
+ layout?: Presentation;
55
+ } & FormFieldStateProps<T>;
56
+
57
+ /**
58
+ * Form field component.
59
+ */
60
+ export type FormFieldComponent = FC<FormFieldComponentProps>;
61
+
62
+ export type FormFieldMap = Record<string, FormFieldComponent>;
63
+
64
+ export type FormFieldProvider = (props: {
65
+ prop: string; // TODO(burdon): Path?
66
+ schema: Schema.Schema<any>;
67
+ fieldProps: FormFieldComponentProps;
68
+ }) => ReactElement | undefined;
69
+
70
+ //
71
+ // FormFieldLabel
72
+ //
73
+
74
+ export type FormFieldLabelProps = {
75
+ asChild?: boolean;
76
+ error?: string;
77
+ } & Pick<FormFieldComponentProps, 'label' | 'readonly'>;
78
+
79
+ export const FormFieldLabel = ({ label, error, readonly, asChild }: FormFieldLabelProps) => {
80
+ const Label = readonly || asChild ? 'span' : Input.Label;
81
+ const labelProps = readonly || asChild ? { className: 'text-description text-sm' } : { classNames: '!mlb-0 text-sm' };
82
+
83
+ return (
84
+ <div role='none' className={mx('flex justify-between items-center', labelSpacing)}>
85
+ <Label {...labelProps}>{label}</Label>
86
+ {error && (
87
+ <Tooltip.Trigger asChild content={error} side='bottom'>
88
+ <Icon icon='ph--warning--regular' size={4} classNames={errorText} />
89
+ </Tooltip.Trigger>
90
+ )}
91
+ </div>
92
+ );
93
+ };
94
+
95
+ FormFieldLabel.displayName = 'Form.FieldLabel';
96
+
97
+ //
98
+ // FormFieldWrapper
99
+ //
100
+
101
+ export type FormFieldWrapperProps<T = any> = Pick<
102
+ FormFieldComponentProps,
103
+ 'readonly' | 'label' | 'layout' | 'getStatus' | 'getValue'
104
+ > & {
105
+ children?: (props: { value: T }) => ReactNode;
106
+ };
107
+
108
+ export const FormFieldWrapper = <T,>(props: FormFieldWrapperProps<T>) => {
109
+ const { children, readonly, layout, label, getStatus, getValue } = props;
110
+ const { status, error } = getStatus();
111
+
112
+ const value = getValue();
113
+ if (layout === 'static' && value == null) {
114
+ return null;
115
+ }
116
+
117
+ const str = String(value ?? '');
118
+
119
+ return (
120
+ <Input.Root validationValence={status}>
121
+ {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} />}
122
+ {layout === 'static' ? <p>{str}</p> : children ? children({ value }) : null}
123
+ {layout === 'full' && (
124
+ <Input.DescriptionAndValidation>
125
+ <Input.Validation>{error}</Input.Validation>
126
+ </Input.DescriptionAndValidation>
127
+ )}
128
+ </Input.Root>
129
+ );
130
+ };
131
+
132
+ //
133
+ // FormFieldErrorBoundary
134
+ //
135
+
136
+ type FormFieldErrorState = {
137
+ error: Error | undefined;
138
+ };
139
+
140
+ type FormFieldErrorBoundaryProps = PropsWithChildren<{
141
+ path?: (string | number)[];
142
+ }>;
143
+
144
+ export class FormFieldErrorBoundary extends Component<FormFieldErrorBoundaryProps, FormFieldErrorState> {
145
+ static getDerivedStateFromError(error: Error): { error: Error } {
146
+ return { error };
147
+ }
148
+
149
+ override state = { error: undefined };
150
+
151
+ override componentDidUpdate(prevProps: FormFieldErrorBoundaryProps) {
152
+ if (prevProps.path !== this.props.path) {
153
+ this.resetError();
154
+ }
155
+ }
156
+
157
+ override render() {
158
+ if (this.state.error) {
159
+ return (
160
+ <div className='flex gap-2 border border-roseFill font-mono text-sm'>
161
+ <span className='bg-roseFill text-surfaceText pli-1 font-thin'>ERROR</span>
162
+ {String(this.props.path?.join('.'))}
163
+ </div>
164
+ );
165
+ }
166
+
167
+ return this.props.children;
168
+ }
169
+
170
+ private resetError() {
171
+ this.setState({ error: undefined });
172
+ }
173
+ }
@@ -0,0 +1,124 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import React, { forwardRef, useMemo } from 'react';
6
+
7
+ import { type AnyProperties } from '@dxos/echo/internal';
8
+ import { type SchemaProperty } from '@dxos/effect';
9
+ import { type ThemedClassName } from '@dxos/react-ui';
10
+ import { mx } from '@dxos/react-ui-theme';
11
+ import { isTruthy } from '@dxos/util';
12
+
13
+ import { type FormHandlerProps } from '../../hooks';
14
+ import { getFormProperties } from '../../util';
15
+
16
+ import { useFormValues } from './Form';
17
+ import { FormField, type FormFieldProps } from './FormField';
18
+ import { FormFieldErrorBoundary, FormFieldLabel } from './FormFieldComponent';
19
+
20
+ export type FormFieldSetProps<T extends AnyProperties> = ThemedClassName<
21
+ {
22
+ label?: string;
23
+ exclude?: (props: SchemaProperty[]) => SchemaProperty[];
24
+ sort?: string[];
25
+ } & Pick<FormHandlerProps<T>, 'schema'> &
26
+ Pick<
27
+ FormFieldProps,
28
+ | 'path'
29
+ | 'autoFocus'
30
+ | 'readonly'
31
+ | 'layout'
32
+ | 'projection'
33
+ | 'fieldMap'
34
+ | 'fieldProvider'
35
+ | 'createSchema'
36
+ | 'createOptionLabel'
37
+ | 'createOptionIcon'
38
+ | 'createInitialValuePath'
39
+ | 'onCreate'
40
+ | 'onQueryRefOptions'
41
+ >
42
+ >;
43
+
44
+ export const FormFieldSet = forwardRef<HTMLDivElement, FormFieldSetProps<any>>(
45
+ ({ classNames, label, schema, readonly, path, exclude, sort, projection, layout, ...props }, forwardRef) => {
46
+ const values = useFormValues(FormFieldSet.displayName!, path);
47
+
48
+ // TODO(burdon): Updates on every value change.
49
+ // Remove values dep if can remove from getSchemaProperties.
50
+ const properties = useMemo(() => {
51
+ if (!schema) {
52
+ return [];
53
+ }
54
+
55
+ // TODO(wittjosiah): Reconcile FormInputAnnotation with projection hidden properties & exclude function.
56
+ const props = getFormProperties(schema.ast);
57
+
58
+ // Use projection-based field management when view and projection are available.
59
+ if (projection) {
60
+ const fieldProjections = projection.getFieldProjections();
61
+ const hiddenProperties = new Set(projection.getHiddenProperties());
62
+
63
+ // Filter properties to only include visible ones and order by projection.
64
+ const visibleProps = props.filter((prop) => !hiddenProperties.has(prop.name.toString()));
65
+ const orderedProps: SchemaProperty[] = [];
66
+
67
+ // Add properties in projection field order.
68
+ for (const fieldProjection of fieldProjections) {
69
+ const fieldPath = String(fieldProjection.field.path);
70
+ const prop = visibleProps.find((p) => p.name === fieldPath);
71
+ if (prop) {
72
+ orderedProps.push(prop);
73
+ }
74
+ }
75
+
76
+ // Add any remaining properties not in projection.
77
+ const projectionPaths = new Set(fieldProjections.map((projection) => String(projection.field.path)));
78
+ const remainingProps = visibleProps.filter((prop) => !projectionPaths.has(prop.name.toString()));
79
+ orderedProps.push(...remainingProps);
80
+ return orderedProps;
81
+ }
82
+
83
+ // Fallback to legacy filter/sort behavior.
84
+ const filtered = exclude ? exclude(props) : props;
85
+ return sort
86
+ ? filtered.sort(({ name: a }, { name: b }) => sort.indexOf(a.toString()) - sort.indexOf(b.toString()))
87
+ : filtered;
88
+ }, [schema, values, exclude, sort, projection?.fields]);
89
+
90
+ if ((readonly || layout === 'static') && values == null) {
91
+ return null;
92
+ }
93
+
94
+ return (
95
+ <div
96
+ role='form'
97
+ className={mx('is-full', layout === 'inline' && 'flex flex-col gap-2', classNames)}
98
+ ref={forwardRef}
99
+ >
100
+ {layout !== 'inline' && label && <FormFieldLabel label={label} asChild />}
101
+ {properties
102
+ .map((property) => {
103
+ const name = property.name.toString();
104
+ return (
105
+ <FormFieldErrorBoundary key={name} path={[...(path ?? []), name]}>
106
+ <FormField
107
+ type={property.type}
108
+ name={name}
109
+ path={[...(path ?? []), name]}
110
+ readonly={readonly}
111
+ layout={layout}
112
+ projection={projection}
113
+ {...props}
114
+ />
115
+ </FormFieldErrorBoundary>
116
+ );
117
+ })
118
+ .filter(isTruthy)}
119
+ </div>
120
+ );
121
+ },
122
+ );
123
+
124
+ FormFieldSet.displayName = 'Form.Fields';
@@ -0,0 +1,3 @@
1
+ # Form
2
+
3
+ - [ ] Layout options (e.g., label on the side.)
@@ -0,0 +1,154 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import * as Option from 'effect/Option';
6
+ import * as SchemaAST from 'effect/SchemaAST';
7
+ import React, { Fragment, useCallback } from 'react';
8
+
9
+ import { findNode, getArrayElementType, getDiscriminatedType, isDiscriminatedUnion, isNestedType } from '@dxos/effect';
10
+ import { invariant } from '@dxos/invariant';
11
+ import { IconButton, useTranslation } from '@dxos/react-ui';
12
+
13
+ import { translationKey } from '../../../translations';
14
+ import { getFormProperties } from '../../../util';
15
+ import { useFormValues } from '../Form';
16
+ import { FormField, type FormFieldProps } from '../FormField';
17
+ import { FormFieldLabel, type FormFieldStateProps } from '../FormFieldComponent';
18
+
19
+ export type ArrayFieldProps = {
20
+ label: string;
21
+ fieldProps: FormFieldStateProps;
22
+ } & FormFieldProps;
23
+
24
+ export const ArrayField = ({
25
+ type,
26
+ path,
27
+ label,
28
+ readonly,
29
+ layout,
30
+ fieldProps: inputProps,
31
+ ...props
32
+ }: ArrayFieldProps) => {
33
+ const { t } = useTranslation(translationKey);
34
+ const elementType = getArrayElementType(type);
35
+ const { onValueChange } = inputProps;
36
+
37
+ // TODO(wittjosiah): The fallback to an empty array stops the form from crashing but isn't immediately live.
38
+ // It doesn't become live until another field is touched, but that's better than the whole form crashing.
39
+ const values = useFormValues(ArrayField.displayName, path) ?? [];
40
+ invariant(Array.isArray(values), `Expected array at: ${path?.join('.')}`);
41
+
42
+ const getDefaultObjectValue = (typeNode: SchemaAST.AST): any => {
43
+ const baseNode = findNode(typeNode, isDiscriminatedUnion);
44
+ const typeLiteral = baseNode ? getDiscriminatedType(baseNode, {}) : findNode(typeNode, SchemaAST.isTypeLiteral);
45
+ if (!typeLiteral) {
46
+ return {};
47
+ }
48
+
49
+ return Object.fromEntries(
50
+ getFormProperties(typeLiteral).map((prop) => {
51
+ const defaultValue = SchemaAST.getDefaultAnnotation(prop.type).pipe((annotation) =>
52
+ Option.getOrUndefined(annotation),
53
+ );
54
+ return [prop.name, defaultValue];
55
+ }),
56
+ );
57
+ };
58
+
59
+ const handleAdd = useCallback(() => {
60
+ const defaultValue =
61
+ isNestedType(type) && elementType ? getDefaultObjectValue(elementType) : getDefaultValue(elementType);
62
+ onValueChange(type, [...values, defaultValue]);
63
+ }, [onValueChange, type, elementType, values]);
64
+
65
+ const handleDelete = useCallback(
66
+ (idx: number) => {
67
+ onValueChange(
68
+ type,
69
+ values.filter((_, i) => i !== idx),
70
+ );
71
+ },
72
+ [onValueChange, type, values],
73
+ );
74
+
75
+ if (!elementType || ((readonly || layout === 'static') && values.length < 1)) {
76
+ return null;
77
+ }
78
+
79
+ return (
80
+ <>
81
+ {(layout !== 'static' || values.length > 0) && <FormFieldLabel readonly={readonly} label={label} asChild />}
82
+
83
+ <div role='none' className='flex flex-col gap-2'>
84
+ {values.map((_, index) => {
85
+ return (
86
+ <div role='none' key={index} className='grid grid-cols-[1fr_min-content] gap-2 last:mb-3'>
87
+ <FormField
88
+ autoFocus={index === values.length - 1}
89
+ type={elementType}
90
+ path={[...(path ?? []), index]}
91
+ readonly={readonly || layout === 'static'}
92
+ layout='inline'
93
+ {...props}
94
+ />
95
+
96
+ {!readonly && layout !== 'static' && (
97
+ <div role='none' className='flex flex-col bs-full justify-end'>
98
+ {/* NOTE: Aligns with center of last field if multi-field object. */}
99
+ <div role='none' className='flex items-center bs-[var(--line-height)]'>
100
+ <IconButton
101
+ icon='ph--x--regular'
102
+ iconOnly
103
+ label={t('button remove')}
104
+ onClick={() => handleDelete(index)}
105
+ classNames='self-center'
106
+ />
107
+ </div>
108
+ </div>
109
+ )}
110
+ </div>
111
+ );
112
+ })}
113
+ </div>
114
+
115
+ {/* TODO(burdon): Get label from schema. */}
116
+ {!readonly && layout !== 'static' && (
117
+ <IconButton
118
+ classNames='flex is-full _mlb-cardSpacingBlock'
119
+ icon='ph--plus--regular'
120
+ label={t('add field')}
121
+ onClick={handleAdd}
122
+ />
123
+ )}
124
+ </>
125
+ );
126
+ };
127
+
128
+ ArrayField.displayName = 'Form.ArrayField';
129
+
130
+ /**
131
+ * Returns the default empty value for a given AST.
132
+ * Used for initializing new array values etc.
133
+ */
134
+ // TODO(wittjosiah): Factor out?
135
+ export const getDefaultValue = (ast?: SchemaAST.AST): any => {
136
+ switch (ast?._tag) {
137
+ case 'StringKeyword': {
138
+ return '';
139
+ }
140
+ case 'NumberKeyword': {
141
+ return 0;
142
+ }
143
+ case 'BooleanKeyword': {
144
+ return false;
145
+ }
146
+ default: {
147
+ if (ast && isNestedType(ast)) {
148
+ return {};
149
+ } else {
150
+ throw new Error(`Unsupported type: ${ast?._tag}`);
151
+ }
152
+ }
153
+ }
154
+ };
@@ -0,0 +1,22 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import React, { useCallback } from 'react';
6
+
7
+ import { Input, type SwitchProps } from '@dxos/react-ui';
8
+
9
+ import { type FormFieldComponentProps, FormFieldWrapper } from '../FormFieldComponent';
10
+
11
+ export const BooleanField = ({ type, readonly, onValueChange, ...props }: FormFieldComponentProps<boolean>) => {
12
+ const handleChange = useCallback<NonNullable<SwitchProps['onCheckedChange']>>(
13
+ (value) => onValueChange?.(type, value),
14
+ [type, onValueChange],
15
+ );
16
+
17
+ return (
18
+ <FormFieldWrapper<boolean> readonly={readonly} {...props}>
19
+ {({ value }) => <Input.Switch disabled={!!readonly} checked={value} onCheckedChange={handleChange} />}
20
+ </FormFieldWrapper>
21
+ );
22
+ };
@@ -0,0 +1,120 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import React, { type ChangeEvent, useCallback, useEffect, useMemo, useState } from 'react';
6
+
7
+ import { GeoLocation, type GeoPoint } from '@dxos/echo/internal';
8
+ import { Input, useTranslation } from '@dxos/react-ui';
9
+ import { safeParseFloat } from '@dxos/util';
10
+
11
+ import { translationKey } from '../../../translations';
12
+ import { type FormFieldComponentProps, FormFieldLabel } from '../FormFieldComponent';
13
+
14
+ export const GeoPointField = ({
15
+ type,
16
+ label,
17
+ readonly,
18
+ layout,
19
+ getStatus,
20
+ getValue,
21
+ onValueChange,
22
+ onBlur,
23
+ }: FormFieldComponentProps<GeoPoint>) => {
24
+ const { t } = useTranslation(translationKey);
25
+ const { status, error } = getStatus();
26
+ const geoPoint = useMemo<GeoPoint>(() => getValue() ?? [0, 0], [getValue]);
27
+ const value = useMemo(() => GeoLocation.fromGeoPoint(geoPoint), [geoPoint]);
28
+
29
+ const [longitudeText, setLongitudeText] = useState(value.longitude?.toString());
30
+ const [latitudeText, setLatitudeText] = useState(value.latitude?.toString());
31
+ useEffect(() => {
32
+ const location = GeoLocation.fromGeoPoint(geoPoint);
33
+ setLongitudeText(location.longitude?.toString());
34
+ setLatitudeText(location.latitude?.toString());
35
+ }, [geoPoint]);
36
+
37
+ const handleChange = useCallback(
38
+ (coordinateType: keyof Pick<GeoLocation, 'longitude' | 'latitude'>, setText: (text: string) => void) =>
39
+ (event: ChangeEvent<HTMLInputElement>) => {
40
+ const inputText = event.target.value;
41
+ setText(inputText);
42
+ if (inputText !== '' && inputText !== '-') {
43
+ const coord = safeParseFloat(inputText);
44
+ if (coord !== undefined && !isNaN(coord)) {
45
+ const currentLocation = GeoLocation.fromGeoPoint(getValue() ?? [0, 0]);
46
+ const newLocation = { ...currentLocation, [coordinateType]: coord };
47
+ const newValue = GeoLocation.toGeoPoint(newLocation);
48
+ onValueChange(type, newValue);
49
+ }
50
+ }
51
+ },
52
+ [type, getValue, onValueChange],
53
+ );
54
+
55
+ if ((readonly || layout === 'static') && !value?.latitude && !value?.longitude) {
56
+ return null;
57
+ }
58
+
59
+ return (
60
+ <Input.Root validationValence={status}>
61
+ {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} asChild />}
62
+ {layout === 'static' ? (
63
+ <LatLng {...value} />
64
+ ) : (
65
+ <div role='none' className='grid grid-cols-2 gap-2'>
66
+ <div>
67
+ <Input.Root>
68
+ {layout !== 'inline' && <Input.Label>{t('latitude label')}</Input.Label>}
69
+ <Input.TextInput
70
+ type='number'
71
+ step='0.00001'
72
+ min='-90'
73
+ max='90'
74
+ disabled={!!readonly}
75
+ placeholder={t('latitude placeholder')}
76
+ value={latitudeText ?? ''}
77
+ onChange={handleChange('latitude', setLatitudeText)}
78
+ onBlur={onBlur}
79
+ />
80
+ </Input.Root>
81
+ </div>
82
+ <div>
83
+ <Input.Root>
84
+ {layout !== 'inline' && <Input.Label>{t('longitude label')}</Input.Label>}
85
+ <Input.TextInput
86
+ type='number'
87
+ step='0.00001'
88
+ min='-180'
89
+ max='180'
90
+ disabled={!!readonly}
91
+ placeholder={t('longitude placeholder')}
92
+ value={longitudeText ?? ''}
93
+ onChange={handleChange('longitude', setLongitudeText)}
94
+ onBlur={onBlur}
95
+ />
96
+ </Input.Root>
97
+ </div>
98
+ </div>
99
+ )}
100
+ </Input.Root>
101
+ );
102
+ };
103
+
104
+ const LatLng = ({ latitude = 0, longitude = 0 }: GeoLocation) => {
105
+ const latHem = latitude >= 0 ? 'N' : 'S';
106
+ const lngHem = longitude >= 0 ? 'E' : 'W';
107
+
108
+ return (
109
+ <span className='inline-flex items-center gap-1'>
110
+ <span>
111
+ <span>{Math.abs(latitude).toFixed(5)}</span>
112
+ <span className='text-subdued'>°{latHem}</span>
113
+ </span>
114
+ <span>
115
+ <span>{Math.abs(longitude).toFixed(5)}</span>
116
+ <span className='text-subdued'>°{lngHem}</span>
117
+ </span>
118
+ </span>
119
+ );
120
+ };