@dxos/react-ui-form 0.9.1-main.c7dcc2e112 → 0.10.0
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.
- package/dist/lib/browser/index.mjs +1883 -1539
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +1883 -1539
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/annotations.d.ts +47 -0
- package/dist/types/src/annotations.d.ts.map +1 -0
- package/dist/types/src/components/FieldEditor/FieldEditor.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.d.ts +12 -4
- package/dist/types/src/components/Form/Form.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.stories.d.ts +46 -65
- package/dist/types/src/components/Form/Form.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/Form.theme.d.ts +252 -0
- package/dist/types/src/components/Form/Form.theme.d.ts.map +1 -0
- package/dist/types/src/components/Form/Form.theme.test.d.ts +2 -0
- package/dist/types/src/components/Form/Form.theme.test.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormControls.d.ts +17 -8
- package/dist/types/src/components/Form/FormControls.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/FormField.d.ts +16 -2
- package/dist/types/src/components/Form/FormField/FormField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/FormFieldHeader.d.ts +27 -0
- package/dist/types/src/components/Form/FormField/FormFieldHeader.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/FormRow.d.ts +85 -0
- package/dist/types/src/components/Form/FormField/FormRow.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/{FormFieldWrapper.stories.d.ts → FormRow.stories.d.ts} +4 -1
- package/dist/types/src/components/Form/FormField/FormRow.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts +1 -0
- package/dist/types/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/AsyncSelectField.d.ts +12 -0
- package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/AsyncSelectField.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/index.d.ts +2 -0
- package/dist/types/src/components/Form/FormField/fields/AsyncSelectField/index.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/AutofillField/AutofillField.d.ts +12 -0
- package/dist/types/src/components/Form/FormField/fields/AutofillField/AutofillField.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/AutofillField/index.d.ts +2 -0
- package/dist/types/src/components/Form/FormField/fields/AutofillField/index.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/BooleanField/BooleanField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/ComboboxField/ComboboxField.d.ts +12 -0
- package/dist/types/src/components/Form/FormField/fields/ComboboxField/ComboboxField.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/ComboboxField/index.d.ts +2 -0
- package/dist/types/src/components/Form/FormField/fields/ComboboxField/index.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.d.ts +1 -1
- package/dist/types/src/components/Form/FormField/fields/GeoPointField/GeoPointField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.d.ts +1 -1
- package/dist/types/src/components/Form/FormField/fields/MarkdownField/MarkdownField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts +11 -0
- package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.test.d.ts +2 -0
- package/dist/types/src/components/Form/FormField/fields/NumberField/NumberField.test.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.d.ts +4 -0
- package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.d.ts.map +1 -0
- package/dist/types/src/components/{Settings/Settings.stories.d.ts → Form/FormField/fields/PasswordField/PasswordField.stories.d.ts} +1 -2
- package/dist/types/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/PasswordField/index.d.ts +2 -0
- package/dist/types/src/components/Form/FormField/fields/PasswordField/index.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormField/fields/RefField/InlineRefField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/RefField/RefField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts +18 -2
- package/dist/types/src/components/Form/FormField/fields/SelectField/SelectField.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/fields/index.d.ts +4 -0
- package/dist/types/src/components/Form/FormField/fields/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/index.d.ts +4 -3
- package/dist/types/src/components/Form/FormField/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormField/presentation.d.ts +22 -0
- package/dist/types/src/components/Form/FormField/presentation.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldSet/FormFieldSet.d.ts +8 -5
- package/dist/types/src/components/Form/FormFieldSet/FormFieldSet.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts +22 -0
- package/dist/types/src/components/Form/FormFieldSet/FormFieldSetContainer.d.ts.map +1 -0
- package/dist/types/src/components/Form/FormFieldSet/index.d.ts +1 -0
- package/dist/types/src/components/Form/FormFieldSet/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts +2 -0
- package/dist/types/src/components/Form/FormLayout/FormLayout.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts +5 -5
- package/dist/types/src/components/Form/FormLayout/FormLayout.stories.d.ts.map +1 -1
- package/dist/types/src/components/Form/FormLayout/index.d.ts +1 -1
- package/dist/types/src/components/Form/FormLayout/index.d.ts.map +1 -1
- package/dist/types/src/components/Form/meta-tags.d.ts +1 -12
- package/dist/types/src/components/Form/meta-tags.d.ts.map +1 -1
- package/dist/types/src/components/ObjectPicker/ObjectPicker.d.ts.map +1 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts +2 -1
- package/dist/types/src/components/ObjectProperties/ObjectProperties.d.ts.map +1 -1
- package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts +2 -2
- package/dist/types/src/components/RefEditor/RefEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.d.ts.map +1 -1
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts +2 -2
- package/dist/types/src/components/ViewEditor/ViewEditor.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +0 -1
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/hooks/index.d.ts +1 -0
- package/dist/types/src/hooks/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useAsyncFieldEffect.d.ts +24 -0
- package/dist/types/src/hooks/useAsyncFieldEffect.d.ts.map +1 -0
- package/dist/types/src/hooks/useFormContext.d.ts +8 -3
- package/dist/types/src/hooks/useFormContext.d.ts.map +1 -1
- package/dist/types/src/hooks/useFormHandler.d.ts +8 -2
- package/dist/types/src/hooks/useFormHandler.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +1 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/testing/TestLayout.d.ts +1 -1
- package/dist/types/src/testing/TestLayout.d.ts.map +1 -1
- package/dist/types/src/testing/schema.d.ts +13 -16
- package/dist/types/src/testing/schema.d.ts.map +1 -1
- package/dist/types/src/types.d.ts +50 -1
- package/dist/types/src/types.d.ts.map +1 -1
- package/dist/types/src/util/index.d.ts +0 -1
- package/dist/types/src/util/index.d.ts.map +1 -1
- package/dist/types/src/util/omit.d.ts +1 -1
- package/dist/types/src/util/omit.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +41 -40
- package/src/annotations.ts +63 -0
- package/src/components/FieldEditor/FieldEditor.tsx +5 -2
- package/src/components/Form/DESIGN.md +24 -0
- package/src/components/Form/Form.stories.tsx +257 -13
- package/src/components/Form/Form.theme.test.ts +33 -0
- package/src/components/Form/Form.theme.ts +86 -0
- package/src/components/Form/Form.tsx +15 -9
- package/src/components/Form/FormControls.tsx +94 -37
- package/src/components/Form/FormField/FormField.tsx +83 -32
- package/src/components/Form/FormField/FormFieldHeader.tsx +68 -0
- package/src/components/Form/FormField/{FormFieldWrapper.stories.tsx → FormRow.stories.tsx} +34 -9
- package/src/components/Form/FormField/FormRow.tsx +298 -0
- package/src/components/Form/FormField/fields/ArrayField/ArrayField.stories.tsx +25 -9
- package/src/components/Form/FormField/fields/ArrayField/ArrayField.tsx +83 -24
- package/src/components/Form/FormField/fields/AsyncSelectField/AsyncSelectField.tsx +59 -0
- package/src/components/Form/FormField/fields/AsyncSelectField/index.ts +5 -0
- package/src/components/Form/FormField/fields/AutofillField/AutofillField.tsx +67 -0
- package/src/components/Form/FormField/fields/AutofillField/index.ts +5 -0
- package/src/components/Form/FormField/fields/BooleanField/BooleanField.tsx +5 -6
- package/src/components/Form/FormField/fields/ComboboxField/ComboboxField.tsx +104 -0
- package/src/components/Form/FormField/fields/ComboboxField/index.ts +5 -0
- package/src/components/Form/FormField/fields/DateField/DateField.tsx +9 -9
- package/src/components/Form/FormField/fields/GeoPointField/GeoPointField.tsx +17 -20
- package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.stories.tsx +2 -2
- package/src/components/Form/FormField/fields/MarkdownField/MarkdownField.tsx +39 -40
- package/src/components/Form/FormField/fields/NumberField/NumberField.test.ts +29 -0
- package/src/components/Form/FormField/fields/NumberField/NumberField.tsx +60 -21
- package/src/components/Form/FormField/fields/PasswordField/PasswordField.stories.tsx +53 -0
- package/src/components/Form/FormField/fields/PasswordField/PasswordField.tsx +41 -0
- package/src/components/Form/FormField/fields/PasswordField/index.ts +5 -0
- package/src/components/Form/FormField/fields/RefField/InlineRefField.tsx +16 -10
- package/src/components/Form/FormField/fields/RefField/RefField.test.ts +8 -0
- package/src/components/Form/FormField/fields/RefField/RefField.tsx +50 -23
- package/src/components/Form/FormField/fields/SelectField/SelectField.tsx +104 -47
- package/src/components/Form/FormField/fields/SelectOptionField/SelectOptionField.tsx +3 -3
- package/src/components/Form/FormField/fields/TextAreaField/TextAreaField.tsx +3 -3
- package/src/components/Form/FormField/fields/TextField/TextField.tsx +3 -3
- package/src/components/Form/FormField/fields/TupleField/TupleField.tsx +1 -1
- package/src/components/Form/FormField/fields/index.ts +4 -0
- package/src/components/Form/FormField/index.ts +5 -3
- package/src/components/Form/FormField/presentation.tsx +32 -0
- package/src/components/Form/FormFieldSet/FormFieldSet.tsx +49 -70
- package/src/components/Form/FormFieldSet/FormFieldSetContainer.tsx +93 -0
- package/src/components/Form/FormFieldSet/index.ts +1 -0
- package/src/components/Form/FormLayout/FormLayout.stories.tsx +7 -5
- package/src/components/Form/FormLayout/FormLayout.tsx +24 -14
- package/src/components/Form/FormLayout/index.ts +1 -1
- package/src/components/Form/meta-tags.test.ts +1 -1
- package/src/components/ObjectForm/ObjectForm.tsx +2 -2
- package/src/components/ObjectPicker/ObjectPicker.tsx +1 -0
- package/src/components/ObjectProperties/ObjectProperties.stories.tsx +1 -1
- package/src/components/ObjectProperties/ObjectProperties.tsx +12 -8
- package/src/components/RefEditor/RefEditor.stories.tsx +2 -2
- package/src/components/RefEditor/RefEditor.tsx +1 -1
- package/src/components/ViewEditor/ViewEditor.stories.tsx +2 -2
- package/src/components/ViewEditor/ViewEditor.test.tsx +8 -2
- package/src/components/ViewEditor/ViewEditor.tsx +3 -3
- package/src/components/index.ts +0 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useAsyncFieldEffect.ts +81 -0
- package/src/hooks/useFormContext.ts +6 -3
- package/src/hooks/useFormHandler.ts +11 -6
- package/src/index.ts +1 -0
- package/src/testing/TestLayout.tsx +15 -12
- package/src/testing/schema.ts +43 -43
- package/src/types.ts +53 -1
- package/src/util/index.ts +0 -1
- package/src/util/omit.ts +25 -11
- package/src/util/properties.test.ts +110 -28
- package/src/util/refs.ts +1 -1
- package/dist/types/src/components/Form/FormField/FieldHeader.d.ts +0 -19
- package/dist/types/src/components/Form/FormField/FieldHeader.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormField/FormFieldWrapper.d.ts +0 -47
- package/dist/types/src/components/Form/FormField/FormFieldWrapper.d.ts.map +0 -1
- package/dist/types/src/components/Form/FormField/FormFieldWrapper.stories.d.ts.map +0 -1
- package/dist/types/src/components/Settings/Settings.d.ts +0 -51
- package/dist/types/src/components/Settings/Settings.d.ts.map +0 -1
- package/dist/types/src/components/Settings/Settings.stories.d.ts.map +0 -1
- package/dist/types/src/components/Settings/SettingsFieldSet.d.ts +0 -43
- package/dist/types/src/components/Settings/SettingsFieldSet.d.ts.map +0 -1
- package/dist/types/src/components/Settings/SettingsItem.d.ts +0 -16
- package/dist/types/src/components/Settings/SettingsItem.d.ts.map +0 -1
- package/dist/types/src/components/Settings/SettingsSelectField.d.ts +0 -18
- package/dist/types/src/components/Settings/SettingsSelectField.d.ts.map +0 -1
- package/dist/types/src/components/Settings/index.d.ts +0 -4
- package/dist/types/src/components/Settings/index.d.ts.map +0 -1
- package/dist/types/src/util/field-type.d.ts +0 -19
- package/dist/types/src/util/field-type.d.ts.map +0 -1
- package/src/components/Form/FormField/FieldHeader.tsx +0 -36
- package/src/components/Form/FormField/FormFieldWrapper.tsx +0 -179
- package/src/components/Settings/AUDIT.md +0 -51
- package/src/components/Settings/Settings.stories.tsx +0 -104
- package/src/components/Settings/Settings.tsx +0 -81
- package/src/components/Settings/SettingsFieldSet.tsx +0 -257
- package/src/components/Settings/SettingsItem.tsx +0 -58
- package/src/components/Settings/SettingsSelectField.tsx +0 -59
- package/src/components/Settings/index.ts +0 -7
- 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 {
|
|
14
|
+
import { FormRow } from '../../FormRow';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
|
-
* Extracts numeric `minimum`/`maximum` bounds
|
|
18
|
-
*
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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 } =
|
|
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
|
-
|
|
76
|
-
if (
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
+
};
|
|
@@ -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 {
|
|
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,
|
|
19
|
-
import { FormFieldLabel } from '../../
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
<
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
<
|
|
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 {
|
|
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 '../../
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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 ||
|
|
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
|
-
{
|
|
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
|
-
//
|
|
230
|
-
//
|
|
231
|
-
// such as backing-object refs
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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 {
|
|
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 {
|
|
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
|
-
<
|
|
44
|
-
|
|
45
|
-
{
|
|
46
|
-
<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
|
-
|
|
52
|
-
<Select.
|
|
53
|
-
<Select.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
+
};
|