@dxos/react-ui-form 0.9.1-main.c7dcc2e112 → 0.9.1-staging.ee54ba693a

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 (211) hide show
  1. package/dist/lib/browser/index.mjs +1883 -1539
  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 +1883 -1539
  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/annotations.d.ts +47 -0
  8. package/dist/types/src/annotations.d.ts.map +1 -0
  9. package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
  10. package/dist/types/src/components/Form/Form.d.ts +12 -4
  11. package/dist/types/src/components/Form/Form.d.ts.map +1 -1
  12. package/dist/types/src/components/Form/Form.stories.d.ts +46 -65
  13. package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
  14. package/dist/types/src/components/Form/Form.theme.d.ts +252 -0
  15. package/dist/types/src/components/Form/Form.theme.d.ts.map +1 -0
  16. package/dist/types/src/components/Form/Form.theme.test.d.ts +2 -0
  17. package/dist/types/src/components/Form/Form.theme.test.d.ts.map +1 -0
  18. package/dist/types/src/components/Form/FormControls.d.ts +17 -8
  19. package/dist/types/src/components/Form/FormControls.d.ts.map +1 -1
  20. package/dist/types/src/components/Form/FormField/FormField.d.ts +16 -2
  21. package/dist/types/src/components/Form/FormField/FormField.d.ts.map +1 -1
  22. package/dist/types/src/components/Form/FormField/FormFieldHeader.d.ts +27 -0
  23. package/dist/types/src/components/Form/FormField/FormFieldHeader.d.ts.map +1 -0
  24. package/dist/types/src/components/Form/FormField/FormRow.d.ts +85 -0
  25. package/dist/types/src/components/Form/FormField/FormRow.d.ts.map +1 -0
  26. package/dist/types/src/components/Form/FormField/{FormFieldWrapper.stories.d.ts → FormRow.stories.d.ts} +4 -1
  27. package/dist/types/src/components/Form/FormField/FormRow.stories.d.ts.map +1 -0
  28. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.d.ts.map +1 -1
  29. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts +1 -0
  30. package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts.map +1 -1
  31. package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/AsyncSelectField.d.ts +12 -0
  32. package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/AsyncSelectField.d.ts.map +1 -0
  33. package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/index.d.ts +2 -0
  34. package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/index.d.ts.map +1 -0
  35. package/dist/types/src/components/Form/FormField/fields/AutofillField/AutofillField.d.ts +12 -0
  36. package/dist/types/src/components/Form/FormField/fields/AutofillField/AutofillField.d.ts.map +1 -0
  37. package/dist/types/src/components/Form/FormField/fields/AutofillField/index.d.ts +2 -0
  38. package/dist/types/src/components/Form/FormField/fields/AutofillField/index.d.ts.map +1 -0
  39. package/dist/types/src/components/Form/FormField/fields/BooleanField/BooleanField.d.ts.map +1 -1
  40. package/dist/types/src/components/Form/FormField/fields/ComboboxField/ComboboxField.d.ts +12 -0
  41. package/dist/types/src/components/Form/FormField/fields/ComboboxField/ComboboxField.d.ts.map +1 -0
  42. package/dist/types/src/components/Form/FormField/fields/ComboboxField/index.d.ts +2 -0
  43. package/dist/types/src/components/Form/FormField/fields/ComboboxField/index.d.ts.map +1 -0
  44. package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.d.ts +1 -1
  45. package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.d.ts.map +1 -1
  46. package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.d.ts +1 -1
  47. package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.d.ts.map +1 -1
  48. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts +11 -0
  49. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts.map +1 -1
  50. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.test.d.ts +2 -0
  51. package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.test.d.ts.map +1 -0
  52. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.d.ts +4 -0
  53. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.d.ts.map +1 -0
  54. package/dist/types/src/components/{Settings/Settings.stories.d.ts → Form/FormField/fields/PasswordField/PasswordField.stories.d.ts} +1 -2
  55. package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.d.ts.map +1 -0
  56. package/dist/types/src/components/Form/FormField/fields/PasswordField/index.d.ts +2 -0
  57. package/dist/types/src/components/Form/FormField/fields/PasswordField/index.d.ts.map +1 -0
  58. package/dist/types/src/components/Form/FormField/fields/RefField/InlineRefField.d.ts.map +1 -1
  59. package/dist/types/src/components/Form/FormField/fields/RefField/RefField.d.ts.map +1 -1
  60. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts +18 -2
  61. package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts.map +1 -1
  62. package/dist/types/src/components/Form/FormField/fields/index.d.ts +4 -0
  63. package/dist/types/src/components/Form/FormField/fields/index.d.ts.map +1 -1
  64. package/dist/types/src/components/Form/FormField/index.d.ts +4 -3
  65. package/dist/types/src/components/Form/FormField/index.d.ts.map +1 -1
  66. package/dist/types/src/components/Form/FormField/presentation.d.ts +22 -0
  67. package/dist/types/src/components/Form/FormField/presentation.d.ts.map +1 -0
  68. package/dist/types/src/components/Form/FormFieldSet/FormFieldSet.d.ts +8 -5
  69. package/dist/types/src/components/Form/FormFieldSet/FormFieldSet.d.ts.map +1 -1
  70. package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts +22 -0
  71. package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts.map +1 -0
  72. package/dist/types/src/components/Form/FormFieldSet/index.d.ts +1 -0
  73. package/dist/types/src/components/Form/FormFieldSet/index.d.ts.map +1 -1
  74. package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts +2 -0
  75. package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts.map +1 -1
  76. package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts +5 -5
  77. package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts.map +1 -1
  78. package/dist/types/src/components/Form/FormLayout/index.d.ts +1 -1
  79. package/dist/types/src/components/Form/FormLayout/index.d.ts.map +1 -1
  80. package/dist/types/src/components/Form/meta-tags.d.ts +1 -12
  81. package/dist/types/src/components/Form/meta-tags.d.ts.map +1 -1
  82. package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
  83. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +2 -1
  84. package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -1
  85. package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts +2 -2
  86. package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts.map +1 -1
  87. package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
  88. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +2 -2
  89. package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
  90. package/dist/types/src/components/index.d.ts +0 -1
  91. package/dist/types/src/components/index.d.ts.map +1 -1
  92. package/dist/types/src/hooks/index.d.ts +1 -0
  93. package/dist/types/src/hooks/index.d.ts.map +1 -1
  94. package/dist/types/src/hooks/useAsyncFieldEffect.d.ts +24 -0
  95. package/dist/types/src/hooks/useAsyncFieldEffect.d.ts.map +1 -0
  96. package/dist/types/src/hooks/useFormContext.d.ts +8 -3
  97. package/dist/types/src/hooks/useFormContext.d.ts.map +1 -1
  98. package/dist/types/src/hooks/useFormHandler.d.ts +8 -2
  99. package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
  100. package/dist/types/src/index.d.ts +1 -0
  101. package/dist/types/src/index.d.ts.map +1 -1
  102. package/dist/types/src/testing/TestLayout.d.ts +1 -1
  103. package/dist/types/src/testing/TestLayout.d.ts.map +1 -1
  104. package/dist/types/src/testing/schema.d.ts +13 -16
  105. package/dist/types/src/testing/schema.d.ts.map +1 -1
  106. package/dist/types/src/types.d.ts +50 -1
  107. package/dist/types/src/types.d.ts.map +1 -1
  108. package/dist/types/src/util/index.d.ts +0 -1
  109. package/dist/types/src/util/index.d.ts.map +1 -1
  110. package/dist/types/src/util/omit.d.ts +1 -1
  111. package/dist/types/src/util/omit.d.ts.map +1 -1
  112. package/dist/types/tsconfig.tsbuildinfo +1 -1
  113. package/package.json +41 -40
  114. package/src/annotations.ts +63 -0
  115. package/src/components/FieldEditor/FieldEditor.tsx +5 -2
  116. package/src/components/Form/DESIGN.md +24 -0
  117. package/src/components/Form/Form.stories.tsx +257 -13
  118. package/src/components/Form/Form.theme.test.ts +33 -0
  119. package/src/components/Form/Form.theme.ts +86 -0
  120. package/src/components/Form/Form.tsx +15 -9
  121. package/src/components/Form/FormControls.tsx +94 -37
  122. package/src/components/Form/FormField/FormField.tsx +83 -32
  123. package/src/components/Form/FormField/FormFieldHeader.tsx +68 -0
  124. package/src/components/Form/FormField/{FormFieldWrapper.stories.tsx → FormRow.stories.tsx} +34 -9
  125. package/src/components/Form/FormField/FormRow.tsx +298 -0
  126. package/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.tsx +25 -9
  127. package/src/components/Form/FormField/fields/ArrayField/ArrayField.tsx +83 -24
  128. package/src/components/Form/FormField/fields/AsyncSelectField/AsyncSelectField.tsx +59 -0
  129. package/src/components/Form/FormField/fields/AsyncSelectField/index.ts +5 -0
  130. package/src/components/Form/FormField/fields/AutofillField/AutofillField.tsx +67 -0
  131. package/src/components/Form/FormField/fields/AutofillField/index.ts +5 -0
  132. package/src/components/Form/FormField/fields/BooleanField/BooleanField.tsx +5 -6
  133. package/src/components/Form/FormField/fields/ComboboxField/ComboboxField.tsx +104 -0
  134. package/src/components/Form/FormField/fields/ComboboxField/index.ts +5 -0
  135. package/src/components/Form/FormField/fields/DateField/DateField.tsx +9 -9
  136. package/src/components/Form/FormField/fields/GeoPointField/GeoPointField.tsx +17 -20
  137. package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.tsx +2 -2
  138. package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.tsx +39 -40
  139. package/src/components/Form/FormField/fields/NumberField/NumberField.test.ts +29 -0
  140. package/src/components/Form/FormField/fields/NumberField/NumberField.tsx +60 -21
  141. package/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.tsx +53 -0
  142. package/src/components/Form/FormField/fields/PasswordField/PasswordField.tsx +41 -0
  143. package/src/components/Form/FormField/fields/PasswordField/index.ts +5 -0
  144. package/src/components/Form/FormField/fields/RefField/InlineRefField.tsx +16 -10
  145. package/src/components/Form/FormField/fields/RefField/RefField.test.ts +8 -0
  146. package/src/components/Form/FormField/fields/RefField/RefField.tsx +50 -23
  147. package/src/components/Form/FormField/fields/SelectField/SelectField.tsx +104 -47
  148. package/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.tsx +3 -3
  149. package/src/components/Form/FormField/fields/TextAreaField/TextAreaField.tsx +3 -3
  150. package/src/components/Form/FormField/fields/TextField/TextField.tsx +3 -3
  151. package/src/components/Form/FormField/fields/TupleField/TupleField.tsx +1 -1
  152. package/src/components/Form/FormField/fields/index.ts +4 -0
  153. package/src/components/Form/FormField/index.ts +5 -3
  154. package/src/components/Form/FormField/presentation.tsx +32 -0
  155. package/src/components/Form/FormFieldSet/FormFieldSet.tsx +49 -70
  156. package/src/components/Form/FormFieldSet/FormFieldSetContainer.tsx +93 -0
  157. package/src/components/Form/FormFieldSet/index.ts +1 -0
  158. package/src/components/Form/FormLayout/FormLayout.stories.tsx +7 -5
  159. package/src/components/Form/FormLayout/FormLayout.tsx +24 -14
  160. package/src/components/Form/FormLayout/index.ts +1 -1
  161. package/src/components/Form/meta-tags.test.ts +1 -1
  162. package/src/components/ObjectForm/ObjectForm.tsx +2 -2
  163. package/src/components/ObjectPicker/ObjectPicker.tsx +1 -0
  164. package/src/components/ObjectProperties/ObjectProperties.stories.tsx +1 -1
  165. package/src/components/ObjectProperties/ObjectProperties.tsx +12 -8
  166. package/src/components/RefEditor/RefEditor.stories.tsx +2 -2
  167. package/src/components/RefEditor/RefEditor.tsx +1 -1
  168. package/src/components/ViewEditor/ViewEditor.stories.tsx +2 -2
  169. package/src/components/ViewEditor/ViewEditor.test.tsx +8 -2
  170. package/src/components/ViewEditor/ViewEditor.tsx +3 -3
  171. package/src/components/index.ts +0 -1
  172. package/src/hooks/index.ts +1 -0
  173. package/src/hooks/useAsyncFieldEffect.ts +81 -0
  174. package/src/hooks/useFormContext.ts +6 -3
  175. package/src/hooks/useFormHandler.ts +11 -6
  176. package/src/index.ts +1 -0
  177. package/src/testing/TestLayout.tsx +15 -12
  178. package/src/testing/schema.ts +43 -43
  179. package/src/types.ts +53 -1
  180. package/src/util/index.ts +0 -1
  181. package/src/util/omit.ts +25 -11
  182. package/src/util/properties.test.ts +110 -28
  183. package/src/util/refs.ts +1 -1
  184. package/dist/types/src/components/Form/FormField/FieldHeader.d.ts +0 -19
  185. package/dist/types/src/components/Form/FormField/FieldHeader.d.ts.map +0 -1
  186. package/dist/types/src/components/Form/FormField/FormFieldWrapper.d.ts +0 -47
  187. package/dist/types/src/components/Form/FormField/FormFieldWrapper.d.ts.map +0 -1
  188. package/dist/types/src/components/Form/FormField/FormFieldWrapper.stories.d.ts.map +0 -1
  189. package/dist/types/src/components/Settings/Settings.d.ts +0 -51
  190. package/dist/types/src/components/Settings/Settings.d.ts.map +0 -1
  191. package/dist/types/src/components/Settings/Settings.stories.d.ts.map +0 -1
  192. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +0 -43
  193. package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +0 -1
  194. package/dist/types/src/components/Settings/SettingsItem.d.ts +0 -16
  195. package/dist/types/src/components/Settings/SettingsItem.d.ts.map +0 -1
  196. package/dist/types/src/components/Settings/SettingsSelectField.d.ts +0 -18
  197. package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +0 -1
  198. package/dist/types/src/components/Settings/index.d.ts +0 -4
  199. package/dist/types/src/components/Settings/index.d.ts.map +0 -1
  200. package/dist/types/src/util/field-type.d.ts +0 -19
  201. package/dist/types/src/util/field-type.d.ts.map +0 -1
  202. package/src/components/Form/FormField/FieldHeader.tsx +0 -36
  203. package/src/components/Form/FormField/FormFieldWrapper.tsx +0 -179
  204. package/src/components/Settings/AUDIT.md +0 -51
  205. package/src/components/Settings/Settings.stories.tsx +0 -104
  206. package/src/components/Settings/Settings.tsx +0 -81
  207. package/src/components/Settings/SettingsFieldSet.tsx +0 -257
  208. package/src/components/Settings/SettingsItem.tsx +0 -58
  209. package/src/components/Settings/SettingsSelectField.tsx +0 -59
  210. package/src/components/Settings/index.ts +0 -7
  211. package/src/util/field-type.ts +0 -60
@@ -11,23 +11,40 @@ import { safeParseFloat } from '@dxos/util';
11
11
 
12
12
  import { type FormFieldRendererProps } from '#types';
13
13
 
14
- import { FormFieldWrapper } from '../../FormFieldWrapper';
14
+ import { FormRow } from '../../FormRow';
15
15
 
16
16
  /**
17
- * Extracts numeric `minimum`/`maximum` bounds from a Refinement AST's JSON schema annotation
18
- * (e.g. produced by Schema.between). Returns undefined when no bounds are declared.
17
+ * Extracts numeric constraints (`minimum`/`maximum` bounds and whether the value must be an integer)
18
+ * by walking the chain of Refinement ASTs and reading each one's JSON schema annotation (e.g. produced
19
+ * by `Schema.between` / `Schema.int`). Returns empty constraints when none are declared.
19
20
  */
20
- const getRefinementBounds = (ast: SchemaAST.AST): { min?: number; max?: number } => {
21
- if (!SchemaAST.isRefinement(ast)) {
22
- return {};
23
- }
24
- const jsonSchema = Option.getOrUndefined(SchemaAST.getJSONSchemaAnnotation(ast));
25
- if (jsonSchema == null) {
26
- return {};
21
+ export const getNumericConstraints = (ast: SchemaAST.AST): { min?: number; max?: number; integer: boolean } => {
22
+ let node: SchemaAST.AST | undefined = ast;
23
+ let min: number | undefined;
24
+ let max: number | undefined;
25
+ let integer = false;
26
+ // Nested refinements (e.g. `Schema.int()` + `Schema.between()`) each carry their own JSON schema
27
+ // fragment, so accumulate across the chain rather than reading only the outermost node.
28
+ while (node && SchemaAST.isRefinement(node)) {
29
+ const jsonSchema = Option.getOrUndefined(SchemaAST.getJSONSchemaAnnotation(node));
30
+ if (jsonSchema != null) {
31
+ // Stacked refinements intersect: keep the strictest bound (largest min, smallest max).
32
+ if ('minimum' in jsonSchema && typeof jsonSchema.minimum === 'number') {
33
+ min = min === undefined ? jsonSchema.minimum : Math.max(min, jsonSchema.minimum);
34
+ }
35
+ if ('maximum' in jsonSchema && typeof jsonSchema.maximum === 'number') {
36
+ max = max === undefined ? jsonSchema.maximum : Math.min(max, jsonSchema.maximum);
37
+ }
38
+ if (
39
+ ('type' in jsonSchema && jsonSchema.type === 'integer') ||
40
+ ('multipleOf' in jsonSchema && jsonSchema.multipleOf === 1)
41
+ ) {
42
+ integer = true;
43
+ }
44
+ }
45
+ node = node.from;
27
46
  }
28
- const min = 'minimum' in jsonSchema && typeof jsonSchema.minimum === 'number' ? jsonSchema.minimum : undefined;
29
- const max = 'maximum' in jsonSchema && typeof jsonSchema.maximum === 'number' ? jsonSchema.maximum : undefined;
30
- return { min, max };
47
+ return { min, max, integer };
31
48
  };
32
49
 
33
50
  export const NumberField = ({
@@ -39,7 +56,23 @@ export const NumberField = ({
39
56
  onBlur,
40
57
  ...props
41
58
  }: FormFieldRendererProps<number>) => {
42
- const { min, max } = getRefinementBounds(type);
59
+ const { min, max, integer } = getNumericConstraints(type);
60
+
61
+ // Clamp to the declared bounds (and round when integer) so the committed value respects the schema's
62
+ // natural limits — `<input type="number">`'s min/max only constrain the spinner, not typed input.
63
+ const clamp = useCallback(
64
+ (n: number) => {
65
+ let value = integer ? Math.round(n) : n;
66
+ if (min !== undefined) {
67
+ value = Math.max(min, value);
68
+ }
69
+ if (max !== undefined) {
70
+ value = Math.min(max, value);
71
+ }
72
+ return value;
73
+ },
74
+ [min, max, integer],
75
+ );
43
76
 
44
77
  // Track raw string input so the user can clear the field before typing a new number.
45
78
  // We only commit to onValueChange when the raw string parses to a valid number.
@@ -64,26 +97,31 @@ export const NumberField = ({
64
97
  setRaw(value);
65
98
  const parsed = safeParseFloat(value);
66
99
  if (parsed !== undefined) {
67
- onValueChange(type, parsed);
100
+ onValueChange(type, clamp(parsed));
68
101
  }
69
102
  },
70
- [type, onValueChange],
103
+ [type, onValueChange, clamp],
71
104
  );
72
105
 
73
106
  const handleBlur = useCallback(
74
107
  (event: React.FocusEvent<HTMLElement>) => {
75
- // If the field was left empty or invalid, reset to the last committed value.
76
- if (safeParseFloat(raw) === undefined) {
108
+ const parsed = safeParseFloat(raw);
109
+ if (parsed === undefined) {
110
+ // The field was left empty or invalid; reset to the last committed value.
77
111
  const committed = getValue();
78
112
  setRaw(committed !== undefined ? String(committed) : '');
113
+ } else {
114
+ // Reflect the clamped value even when it equals the previously committed one (so `externalValue`
115
+ // doesn't change), otherwise the input keeps showing the out-of-range text the user typed.
116
+ setRaw(String(clamp(parsed)));
79
117
  }
80
118
  onBlur(event);
81
119
  },
82
- [raw, getValue, onBlur],
120
+ [raw, getValue, onBlur, clamp],
83
121
  );
84
122
 
85
123
  return (
86
- <FormFieldWrapper<number> readonly={readonly} getValue={getValue} {...props}>
124
+ <FormRow<number> readonly={readonly} getValue={getValue} {...props}>
87
125
  {() => (
88
126
  <Input.TextInput
89
127
  type='number'
@@ -92,10 +130,11 @@ export const NumberField = ({
92
130
  value={raw}
93
131
  min={min}
94
132
  max={max}
133
+ step={integer ? 1 : undefined}
95
134
  onChange={handleChange}
96
135
  onBlur={handleBlur}
97
136
  />
98
137
  )}
99
- </FormFieldWrapper>
138
+ </FormRow>
100
139
  );
101
140
  };
@@ -0,0 +1,53 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { type Meta, type StoryObj } from '@storybook/react-vite';
6
+ import * as Schema from 'effect/Schema';
7
+ import React, { useState } from 'react';
8
+
9
+ import { Format } from '@dxos/echo';
10
+ import { withLayout, withTheme } from '@dxos/react-ui/testing';
11
+
12
+ import { translations } from '#translations';
13
+
14
+ import { TestLayout } from '../../../../../testing';
15
+ import { Form } from '../../../Form';
16
+
17
+ const schema = Schema.Struct({
18
+ name: Schema.String.annotations({ title: 'Name' }).pipe(Format.FormatAnnotation.set(Format.TypeFormat.Password)),
19
+ }).pipe(Schema.mutable);
20
+
21
+ type Values = Schema.Schema.Type<typeof schema>;
22
+
23
+ const DefaultStory = () => {
24
+ const [values, setValues] = useState<Values>({ name: 'Alice' });
25
+ return (
26
+ <TestLayout json={values}>
27
+ <Form.Root
28
+ schema={schema}
29
+ values={values}
30
+ onValuesChanged={(values) => setValues((prev) => ({ ...prev, ...values }))}
31
+ >
32
+ <Form.Viewport>
33
+ <Form.Content>
34
+ <Form.FieldSet />
35
+ </Form.Content>
36
+ </Form.Viewport>
37
+ </Form.Root>
38
+ </TestLayout>
39
+ );
40
+ };
41
+
42
+ const meta = {
43
+ title: 'ui/react-ui-form/FormField/PasswordField',
44
+ render: DefaultStory,
45
+ decorators: [withTheme(), withLayout({ layout: 'fullscreen' })],
46
+ parameters: { layout: 'fullscreen', translations },
47
+ } satisfies Meta<typeof DefaultStory>;
48
+
49
+ export default meta;
50
+
51
+ type Story = StoryObj<typeof meta>;
52
+
53
+ export const Default: Story = {};
@@ -0,0 +1,41 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import React, { useCallback } from 'react';
6
+
7
+ import { Input, type TextInputProps } from '@dxos/react-ui';
8
+
9
+ import { type FormFieldRendererProps } from '#types';
10
+
11
+ import { FormRow } from '../../FormRow';
12
+
13
+ export const PasswordField = ({
14
+ type,
15
+ readonly,
16
+ placeholder,
17
+ onBlur,
18
+ onValueChange,
19
+ ...props
20
+ }: FormFieldRendererProps<string>) => {
21
+ const handleChange = useCallback<NonNullable<TextInputProps['onChange']>>(
22
+ (event) => onValueChange(type, event.target.value),
23
+ [type, onValueChange],
24
+ );
25
+
26
+ return (
27
+ <FormRow<string> readonly={readonly} {...props}>
28
+ {({ value = '' }) => (
29
+ <Input.TextInput
30
+ type='password'
31
+ noAutoFill
32
+ disabled={!!readonly}
33
+ placeholder={placeholder}
34
+ value={value}
35
+ onBlur={onBlur}
36
+ onChange={handleChange}
37
+ />
38
+ )}
39
+ </FormRow>
40
+ );
41
+ };
@@ -0,0 +1,5 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ export * from './PasswordField';
@@ -10,13 +10,14 @@ import { useType as defaultUseType } from '@dxos/echo-react';
10
10
  import { ReferenceAnnotationId, type ReferenceAnnotationValue } from '@dxos/echo/Annotation';
11
11
  import { SchemaEx } from '@dxos/effect';
12
12
  import { DXN, URI } from '@dxos/keys';
13
- import { Button, Icon, Input, useTranslation } from '@dxos/react-ui';
13
+ import { IconButton, Input, useTranslation } from '@dxos/react-ui';
14
14
 
15
15
  import { translationKey } from '#translations';
16
16
 
17
17
  import { omitId } from '../../../../../util';
18
- import { FormContent, FormFieldSet, FormRoot } from '../../../FormControls';
19
- import { FormFieldLabel } from '../../FormFieldWrapper';
18
+ import { FormContent, FormFieldSetController, FormRoot } from '../../../FormControls';
19
+ import { FormFieldLabel } from '../../FormRow';
20
+ import { presentationFor } from '../../presentation';
20
21
  import { type RefFieldProps } from './RefField';
21
22
 
22
23
  /**
@@ -34,7 +35,8 @@ export const InlineRefField = (props: RefFieldProps) => {
34
35
  readonly,
35
36
  label,
36
37
  jsonPath,
37
- layout,
38
+ presentation,
39
+ required,
38
40
  db,
39
41
  getValue,
40
42
  onValueChange,
@@ -42,6 +44,7 @@ export const InlineRefField = (props: RefFieldProps) => {
42
44
  useType = defaultUseType,
43
45
  } = props;
44
46
  const { t } = useTranslation(translationKey);
47
+ const resolved = presentationFor(presentation);
45
48
 
46
49
  const reference = getValue() as Ref.Ref<any> | undefined;
47
50
  const typename = useMemo(
@@ -67,16 +70,19 @@ export const InlineRefField = (props: RefFieldProps) => {
67
70
 
68
71
  return (
69
72
  <Input.Root>
70
- {layout !== 'inline' && <FormFieldLabel readonly={readonly} label={label} path={jsonPath} />}
73
+ {resolved.showLabel && <FormFieldLabel readonly={readonly} required={required} label={label} path={jsonPath} />}
71
74
  {reference ? (
72
75
  <InlineForm reference={reference} db={db} readonly={readonly} useType={useType} />
73
76
  ) : (
74
77
  !readonly &&
75
78
  onCreate && (
76
- <Button classNames='w-full gap-form-gap' disabled={!createType || !db} onClick={() => void handleCreate()}>
77
- <Icon icon='ph--plus--regular' size={4} />
78
- <span>{label || t('ref-field.placeholder')}</span>
79
- </Button>
79
+ <IconButton
80
+ classNames='w-full gap-form-gap'
81
+ disabled={!createType || !db}
82
+ icon='ph--plus--regular'
83
+ label={label || t('ref-field.placeholder')}
84
+ onClick={() => void handleCreate()}
85
+ />
80
86
  )
81
87
  )}
82
88
  </Input.Root>
@@ -132,7 +138,7 @@ const InlineForm = ({ reference, db, readonly, useType = defaultUseType }: Inlin
132
138
  return (
133
139
  <FormRoot db={db} schema={formSchema} defaultValues={defaultValues as any} onValuesChanged={handleChange}>
134
140
  <FormContent>
135
- <FormFieldSet collapsible readonly={readonly} />
141
+ <FormFieldSetController collapsible readonly={readonly} />
136
142
  </FormContent>
137
143
  </FormRoot>
138
144
  );
@@ -29,6 +29,14 @@ describe('findRefOption', () => {
29
29
  expect(findRefOption({ '/': LOCAL }, options)?.label).toBe('Target');
30
30
  });
31
31
 
32
+ test('matches a keyed-entity ref by direct URI equality', ({ expect }) => {
33
+ // Keyed/registry entities (skills, operations) are referenced by a named DXN with no parseable
34
+ // EID; the option id and the ref uri are the same named URI, so they match by direct equality.
35
+ const named = 'dxn:type:example.com/skill/research';
36
+ const keyedOptions: RefOption[] = [...options, { id: named, label: 'Research' }];
37
+ expect(findRefOption({ '/': named }, keyedOptions)?.label).toBe('Research');
38
+ });
39
+
32
40
  test('returns undefined for a non-ref value', ({ expect }) => {
33
41
  expect(findRefOption('not-a-ref', options)).toBeUndefined();
34
42
  expect(findRefOption(undefined, options)).toBeUndefined();
@@ -6,7 +6,7 @@ import React, { useCallback, useMemo, useState } from 'react';
6
6
 
7
7
  import '@dxos/lit-ui/dx-tag-picker.pcss';
8
8
  import { Entity, Filter, Obj, Query, Ref, Scope, Type } from '@dxos/echo';
9
- import { useQuery, useType as defaultUseType } from '@dxos/echo-react';
9
+ import { useType as defaultUseType, useQuery } from '@dxos/echo-react';
10
10
  import { ANY_OBJECT_TYPENAME, ReferenceAnnotationId, type ReferenceAnnotationValue } from '@dxos/echo/internal';
11
11
  import { SchemaEx } from '@dxos/effect';
12
12
  import { DXN, EID, URI } from '@dxos/keys';
@@ -19,7 +19,8 @@ import { type CreateOptions, type FormFieldRendererProps, type RefFieldDataProps
19
19
 
20
20
  import { omitHiddenFormFields, omitId } from '../../../../../util';
21
21
  import { ObjectPicker } from '../../../../ObjectPicker';
22
- import { FormFieldLabel } from '../../FormFieldWrapper';
22
+ import { FormFieldLabel } from '../../FormRow';
23
+ import { presentationFor } from '../../presentation';
23
24
 
24
25
  // TODO(burdon): Factor out.
25
26
  const isRefSnapshot = (val: any): val is { '/': string } => {
@@ -40,7 +41,14 @@ export const findRefOption = (value: unknown, options: RefOption[]): RefOption |
40
41
  if (!isRef && !isRefSnapshot(value)) {
41
42
  return undefined;
42
43
  }
43
- const valueEid = EID.tryParse(isRef ? value.uri : value['/']);
44
+ const valueUri = isRef ? value.uri : value['/'];
45
+ // Keyed/registry entities (skills, operations) are referenced by a named DXN rather than an
46
+ // entity-id, so they carry no parseable EID; match those by direct URI equality against the option id.
47
+ const directMatch = options.find((option) => option.id === valueUri);
48
+ if (directMatch) {
49
+ return directMatch;
50
+ }
51
+ const valueEid = EID.tryParse(valueUri);
44
52
  if (!valueEid) {
45
53
  return undefined;
46
54
  }
@@ -86,7 +94,7 @@ const defaultUseResults: NonNullable<RefFieldProps['useResults']> = (db, typenam
86
94
  : typename === ANY_OBJECT_TYPENAME
87
95
  ? // Untyped refs show space objects only; the registry is too broad for "any".
88
96
  Query.select(Filter.everything())
89
- : // Include registry scope so keyed entities (blueprints, operations) appear as options.
97
+ : // Include registry scope so keyed entities (skills, operations) appear as options.
90
98
  Query.select(Filter.type(DXN.make(typename))).from(Scope.space(), Scope.registry()),
91
99
  );
92
100
 
@@ -99,7 +107,8 @@ export const RefField = (props: RefFieldProps) => {
99
107
  label,
100
108
  jsonPath,
101
109
  placeholder,
102
- layout,
110
+ presentation,
111
+ required,
103
112
  getStatus,
104
113
  getValue,
105
114
  createOptionLabel,
@@ -111,10 +120,12 @@ export const RefField = (props: RefFieldProps) => {
111
120
  useResults = defaultUseResults,
112
121
  getOptions = defaultGetOptions,
113
122
  onCreate,
123
+ resolveCreateEntry,
114
124
  onValueChange,
115
125
  } = props;
116
126
  const { t } = useTranslation(translationKey);
117
127
  const { status, error } = getStatus();
128
+ const resolved = presentationFor(presentation);
118
129
 
119
130
  const typename = useMemo(
120
131
  () => (type ? SchemaEx.findAnnotation<ReferenceAnnotationValue>(type, ReferenceAnnotationId)?.typename : undefined),
@@ -145,24 +156,31 @@ export const RefField = (props: RefFieldProps) => {
145
156
  const selectedIds = useMemo(() => (item ? [item.id] : []), [item]);
146
157
  const createSchema = useType(db, typename && typename !== ANY_OBJECT_TYPENAME ? DXN.make(typename) : undefined);
147
158
 
159
+ // Per-typename override supplied by the caller (e.g. SpaceCapabilities.CreateObjectEntry).
160
+ const createEntry = typename ? resolveCreateEntry?.(typename) : undefined;
161
+
148
162
  // Lift the popover open state so we can dismiss it after a successful create.
149
163
  const [open, setOpen] = useState(false);
150
164
 
151
165
  const handleCreate = useCallback(
152
166
  async (values: any) => {
153
- if (!createSchema || !onCreate) {
154
- return;
155
- }
156
- const newObject = await onCreate(createSchema, values);
157
- if (newObject) {
158
- // Wire the newly-created object into this slot's form value. ArrayField
159
- // owns the array; this RefField represents a single slot, so writing a
160
- // Ref via `onValueChange` populates that slot.
161
- onValueChange(type, Ref.make(newObject));
167
+ // Wire the newly-created object into this slot's form value. ArrayField
168
+ // owns the array; this RefField represents a single slot, so writing a
169
+ // Ref via `onValueChange` populates that slot.
170
+ if (createEntry?.createObject && db) {
171
+ const newObject = await createEntry.createObject(values, db);
172
+ if (newObject) {
173
+ onValueChange(type, Ref.make(newObject));
174
+ }
175
+ } else if (createSchema && onCreate) {
176
+ const newObject = await onCreate(createSchema, values);
177
+ if (newObject) {
178
+ onValueChange(type, Ref.make(newObject));
179
+ }
162
180
  }
163
181
  setOpen(false);
164
182
  },
165
- [createSchema, onCreate, type, onValueChange],
183
+ [createEntry, db, createSchema, onCreate, type, onValueChange],
166
184
  );
167
185
 
168
186
  const handleUpdate = useCallback(
@@ -185,13 +203,15 @@ export const RefField = (props: RefFieldProps) => {
185
203
  [item, handleUpdate],
186
204
  );
187
205
 
188
- if (!typename || ((readonly || layout === 'static') && !item)) {
206
+ if (!typename || ((readonly || resolved.isStatic) && !item)) {
189
207
  return null;
190
208
  }
191
209
 
192
210
  return (
193
211
  <Input.Root validationValence={status}>
194
- {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} path={jsonPath} />}
212
+ {resolved.showLabel && (
213
+ <FormFieldLabel error={error} readonly={readonly} required={required} label={label} path={jsonPath} />
214
+ )}
195
215
  <div>
196
216
  {readonly ? (
197
217
  !item ? (
@@ -226,22 +246,29 @@ export const RefField = (props: RefFieldProps) => {
226
246
  classNames='dx-card-popover-width'
227
247
  options={options}
228
248
  selectedIds={selectedIds}
229
- // Strip hidden (`FormInputAnnotation.set(false)`) fields so the
230
- // form's validator doesn't reject required-but-hidden fields
231
- // such as backing-object refs supplied by a `FactoryAnnotation`.
232
- createSchema={createSchema && omitHiddenFormFields(omitId(Type.getSchema(createSchema)))}
249
+ // Prefer the plugin-registered inputSchema; fall back to stripping hidden fields
250
+ // (`FormInputAnnotation.set(false)`) from the raw ECHO type schema so the form's
251
+ // validator doesn't reject required-but-hidden fields such as backing-object refs
252
+ // supplied by a `FactoryAnnotation`.
253
+ createSchema={
254
+ createEntry?.inputSchema ??
255
+ (createSchema && omitHiddenFormFields(omitId(Type.getSchema(createSchema))))
256
+ }
233
257
  createOptionLabel={createOptionLabel}
234
258
  createOptionIcon={createOptionIcon}
235
259
  createInitialValuePath={createInitialValuePath}
236
260
  createFieldMap={createFieldMap}
237
- onCreate={handleCreate}
261
+ // Offer inline create when the caller wired a handler OR a plugin-registered
262
+ // createObject override is available. A resolvable `createSchema` alone is not
263
+ // enough (e.g. operation refs, whose objects can't be created ad hoc).
264
+ onCreate={onCreate || createEntry?.createObject ? handleCreate : undefined}
238
265
  onSelect={handleSelect}
239
266
  />
240
267
  </ObjectPicker.Portal>
241
268
  </ObjectPicker.Root>
242
269
  )}
243
270
  </div>
244
- {layout === 'full' && <Input.DescriptionAndValidation>{error}</Input.DescriptionAndValidation>}
271
+ {resolved.showError && <Input.DescriptionAndValidation>{error}</Input.DescriptionAndValidation>}
245
272
  </Input.Root>
246
273
  );
247
274
  };
@@ -4,71 +4,59 @@
4
4
 
5
5
  import React, { useCallback } from 'react';
6
6
 
7
- import { Icon, Input, Select, type SelectRootProps } from '@dxos/react-ui';
7
+ import { invariant } from '@dxos/invariant';
8
+ import { Icon, Select, type SelectRootProps } from '@dxos/react-ui';
8
9
  import { getStyles } from '@dxos/ui-theme';
9
10
 
10
- import { type FormFieldRendererProps } from '#types';
11
+ import { type FormFieldRenderer, type FormFieldRendererProps } from '#types';
11
12
 
12
- import { FormFieldLabel } from '../../FormFieldWrapper';
13
+ import { FormRow } from '../../FormRow';
13
14
 
14
15
  export type SelectFieldOptions = FormFieldRendererProps & {
15
16
  options?: Array<{ value: string | number; label?: string; secondaryLabel?: string; icon?: string; iconHue?: string }>;
16
17
  };
17
18
 
18
- export const SelectField = ({
19
- type,
20
- readonly,
21
- label,
22
- jsonPath,
23
- layout,
24
- placeholder,
25
- options,
26
- getStatus,
27
- getValue,
28
- onValueChange,
29
- }: SelectFieldOptions) => {
30
- const { status, error } = getStatus();
31
- const value = getValue() as string | undefined;
32
-
19
+ export const SelectField = ({ type, readonly, placeholder, options, onValueChange, ...props }: SelectFieldOptions) => {
33
20
  const handleValueChange = useCallback<NonNullable<SelectRootProps['onValueChange']>>(
34
21
  (value) => onValueChange(type, value),
35
22
  [type, onValueChange],
36
23
  );
37
24
 
38
- if ((readonly || layout === 'static') && value == null) {
39
- return null;
40
- }
41
-
42
25
  return (
43
- <Input.Root validationValence={status}>
44
- {layout !== 'inline' && <FormFieldLabel error={error} readonly={readonly} label={label} path={jsonPath} />}
45
- {layout === 'static' ? (
46
- <p>{options?.find(({ value: optionValue }) => optionValue === value)?.label ?? String(value)}</p>
47
- ) : (
26
+ <FormRow<string>
27
+ readonly={readonly}
28
+ renderStatic={(value) => (
29
+ <p className='truncate min-w-0'>
30
+ {options?.find(({ value: optionValue }) => optionValue === value)?.label ?? String(value)}
31
+ </p>
32
+ )}
33
+ {...props}
34
+ >
35
+ {({ value }) => (
48
36
  <Select.Root value={value} onValueChange={handleValueChange}>
49
- {/* TODO(burdon): Placeholder not working? */}
50
37
  <Select.TriggerButton classNames='w-full' disabled={!!readonly} placeholder={placeholder} />
51
- <Select.Portal>
52
- <Select.Content>
53
- <Select.Viewport>
54
- {options?.map(({ value, label, secondaryLabel, icon, iconHue }) => (
55
- // NOTE: Numeric values are converted to and from strings.
56
- <Select.Option key={String(value)} value={String(value)}>
57
- <span className='flex items-center flex-row gap-2'>
58
- {icon && <Icon icon={icon} classNames={getIconHueStyles(iconHue)} />}
59
- {label ?? String(value)}
60
- {secondaryLabel && <span className='text-subdued text-xs'>{secondaryLabel}</span>}
61
- </span>
62
- </Select.Option>
63
- ))}
64
- </Select.Viewport>
65
- <Select.Arrow />
66
- </Select.Content>
67
- </Select.Portal>
38
+ {options?.length !== 0 && (
39
+ <Select.Portal>
40
+ <Select.Content>
41
+ <Select.Viewport>
42
+ {options?.map(({ value, label, secondaryLabel, icon, iconHue }) => (
43
+ // NOTE: Numeric values are converted to and from strings.
44
+ <Select.Option key={String(value)} value={String(value)}>
45
+ <span className='flex items-center flex-row gap-2'>
46
+ {icon && <Icon icon={icon} classNames={getIconHueStyles(iconHue)} />}
47
+ {label ?? String(value)}
48
+ {secondaryLabel && <span className='text-subdued text-xs'>{secondaryLabel}</span>}
49
+ </span>
50
+ </Select.Option>
51
+ ))}
52
+ </Select.Viewport>
53
+ <Select.Arrow />
54
+ </Select.Content>
55
+ </Select.Portal>
56
+ )}
68
57
  </Select.Root>
69
58
  )}
70
- {layout === 'full' && <Input.DescriptionAndValidation>{error}</Input.DescriptionAndValidation>}
71
- </Input.Root>
59
+ </FormRow>
72
60
  );
73
61
  };
74
62
 
@@ -77,3 +65,72 @@ const getIconHueStyles = (iconHue?: string): string | undefined => {
77
65
 
78
66
  return styles?.fg;
79
67
  };
68
+
69
+ export type SelectFieldOption = {
70
+ value: string;
71
+ label?: string;
72
+ };
73
+
74
+ export type CreateSelectFieldOptions = {
75
+ /** Options to display. Strings are used as both value and label. */
76
+ options: ReadonlyArray<string | SelectFieldOption>;
77
+ /** Label for the sentinel option that maps to `undefined`. Defaults to `'Default'`. Pass `null` to omit. */
78
+ defaultLabel?: string | null;
79
+ };
80
+
81
+ /**
82
+ * Factory for a `fieldMap` entry (keyed by JSON path) that renders a Select over a fixed list of
83
+ * options, with an optional sentinel mapping to `undefined`. Use for fields whose choices come from a
84
+ * runtime list rather than the schema (e.g. available model ids).
85
+ */
86
+ export const createSelectField = ({
87
+ options,
88
+ defaultLabel = 'Default',
89
+ }: CreateSelectFieldOptions): FormFieldRenderer => {
90
+ const normalized = options.map((option) => (typeof option === 'string' ? { value: option, label: option } : option));
91
+ const hasDefault = defaultLabel !== null;
92
+ // The sentinel maps to `undefined`. Radix forbids an empty-string `Select.Item` value, so use a
93
+ // non-empty placeholder; reserved (a real option with this value would be unrepresentable).
94
+ const sentinel = '__default__';
95
+ invariant(
96
+ !normalized.some((option) => option.value === sentinel),
97
+ `createSelectField: option value '${sentinel}' is reserved.`,
98
+ );
99
+
100
+ return ({ type, readonly, onValueChange, ...props }: FormFieldRendererProps<string | undefined>) => (
101
+ <FormRow<string>
102
+ readonly={readonly}
103
+ renderStatic={(value) => (
104
+ <p className='truncate min-w-0'>
105
+ {normalized.find((option) => option.value === value)?.label ?? String(value ?? '')}
106
+ </p>
107
+ )}
108
+ {...props}
109
+ >
110
+ {({ value }) => (
111
+ <Select.Root
112
+ disabled={!!readonly}
113
+ value={value ?? sentinel}
114
+ onValueChange={(next) => onValueChange(type, hasDefault && next === sentinel ? undefined : next)}
115
+ >
116
+ <Select.TriggerButton classNames='w-full' disabled={!!readonly} />
117
+ {normalized.length > 0 && (
118
+ <Select.Portal>
119
+ <Select.Content>
120
+ <Select.Viewport>
121
+ {hasDefault && <Select.Option value={sentinel}>{defaultLabel}</Select.Option>}
122
+ {normalized.map((option) => (
123
+ <Select.Option key={option.value} value={option.value}>
124
+ {option.label ?? option.value}
125
+ </Select.Option>
126
+ ))}
127
+ </Select.Viewport>
128
+ <Select.Arrow />
129
+ </Select.Content>
130
+ </Select.Portal>
131
+ )}
132
+ </Select.Root>
133
+ )}
134
+ </FormRow>
135
+ );
136
+ };