@dxos/react-ui-form 0.7.5-main.9d2a38b → 0.7.5-main.e94eead

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