@tai42/studio-sdk 0.2.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/LICENSE +202 -0
- package/NOTICE +15 -0
- package/README.md +318 -0
- package/SECURITY.md +51 -0
- package/dist/components/badge.d.ts +13 -0
- package/dist/components/badge.d.ts.map +1 -0
- package/dist/components/badge.js +14 -0
- package/dist/components/badge.js.map +1 -0
- package/dist/components/checkbox.d.ts +24 -0
- package/dist/components/checkbox.d.ts.map +1 -0
- package/dist/components/checkbox.js +31 -0
- package/dist/components/checkbox.js.map +1 -0
- package/dist/components/code-block.d.ts +7 -0
- package/dist/components/code-block.d.ts.map +1 -0
- package/dist/components/code-block.js +23 -0
- package/dist/components/code-block.js.map +1 -0
- package/dist/components/completion-input.d.ts +31 -0
- package/dist/components/completion-input.d.ts.map +1 -0
- package/dist/components/completion-input.js +61 -0
- package/dist/components/completion-input.js.map +1 -0
- package/dist/components/components.css +2212 -0
- package/dist/components/confirm-dialog.d.ts +40 -0
- package/dist/components/confirm-dialog.d.ts.map +1 -0
- package/dist/components/confirm-dialog.js +11 -0
- package/dist/components/confirm-dialog.js.map +1 -0
- package/dist/components/control-styles.d.ts +12 -0
- package/dist/components/control-styles.d.ts.map +1 -0
- package/dist/components/control-styles.js +14 -0
- package/dist/components/control-styles.js.map +1 -0
- package/dist/components/copy-field.d.ts +8 -0
- package/dist/components/copy-field.d.ts.map +1 -0
- package/dist/components/copy-field.js +106 -0
- package/dist/components/copy-field.js.map +1 -0
- package/dist/components/dialog.d.ts +21 -0
- package/dist/components/dialog.d.ts.map +1 -0
- package/dist/components/dialog.js +25 -0
- package/dist/components/dialog.js.map +1 -0
- package/dist/components/drawer.d.ts +22 -0
- package/dist/components/drawer.d.ts.map +1 -0
- package/dist/components/drawer.js +32 -0
- package/dist/components/drawer.js.map +1 -0
- package/dist/components/error-boundary.d.ts +33 -0
- package/dist/components/error-boundary.d.ts.map +1 -0
- package/dist/components/error-boundary.js +36 -0
- package/dist/components/error-boundary.js.map +1 -0
- package/dist/components/extension-combo-builder.d.ts +70 -0
- package/dist/components/extension-combo-builder.d.ts.map +1 -0
- package/dist/components/extension-combo-builder.js +154 -0
- package/dist/components/extension-combo-builder.js.map +1 -0
- package/dist/components/extension-grouping.d.ts +65 -0
- package/dist/components/extension-grouping.d.ts.map +1 -0
- package/dist/components/extension-grouping.js +78 -0
- package/dist/components/extension-grouping.js.map +1 -0
- package/dist/components/extension-picker.d.ts +35 -0
- package/dist/components/extension-picker.d.ts.map +1 -0
- package/dist/components/extension-picker.js +31 -0
- package/dist/components/extension-picker.js.map +1 -0
- package/dist/components/external-link.d.ts +19 -0
- package/dist/components/external-link.d.ts.map +1 -0
- package/dist/components/external-link.js +12 -0
- package/dist/components/external-link.js.map +1 -0
- package/dist/components/field.d.ts +64 -0
- package/dist/components/field.d.ts.map +1 -0
- package/dist/components/field.js +56 -0
- package/dist/components/field.js.map +1 -0
- package/dist/components/fleet-report.d.ts +39 -0
- package/dist/components/fleet-report.d.ts.map +1 -0
- package/dist/components/fleet-report.js +50 -0
- package/dist/components/fleet-report.js.map +1 -0
- package/dist/components/fonts.css +22 -0
- package/dist/components/icons.d.ts +142 -0
- package/dist/components/icons.d.ts.map +1 -0
- package/dist/components/icons.js +139 -0
- package/dist/components/icons.js.map +1 -0
- package/dist/components/inputs.d.ts +52 -0
- package/dist/components/inputs.d.ts.map +1 -0
- package/dist/components/inputs.js +35 -0
- package/dist/components/inputs.js.map +1 -0
- package/dist/components/json-diff.d.ts +40 -0
- package/dist/components/json-diff.d.ts.map +1 -0
- package/dist/components/json-diff.js +119 -0
- package/dist/components/json-diff.js.map +1 -0
- package/dist/components/json-tree.d.ts +8 -0
- package/dist/components/json-tree.d.ts.map +1 -0
- package/dist/components/json-tree.js +70 -0
- package/dist/components/json-tree.js.map +1 -0
- package/dist/components/modal-focus.d.ts +15 -0
- package/dist/components/modal-focus.d.ts.map +1 -0
- package/dist/components/modal-focus.js +136 -0
- package/dist/components/modal-focus.js.map +1 -0
- package/dist/components/page-header.d.ts +56 -0
- package/dist/components/page-header.d.ts.map +1 -0
- package/dist/components/page-header.js +24 -0
- package/dist/components/page-header.js.map +1 -0
- package/dist/components/primitives.d.ts +186 -0
- package/dist/components/primitives.d.ts.map +1 -0
- package/dist/components/primitives.js +204 -0
- package/dist/components/primitives.js.map +1 -0
- package/dist/components/progress-bar.d.ts +29 -0
- package/dist/components/progress-bar.d.ts.map +1 -0
- package/dist/components/progress-bar.js +48 -0
- package/dist/components/progress-bar.js.map +1 -0
- package/dist/components/radio-group.d.ts +37 -0
- package/dist/components/radio-group.d.ts.map +1 -0
- package/dist/components/radio-group.js +56 -0
- package/dist/components/radio-group.js.map +1 -0
- package/dist/components/reveal-input.d.ts +13 -0
- package/dist/components/reveal-input.d.ts.map +1 -0
- package/dist/components/reveal-input.js +31 -0
- package/dist/components/reveal-input.js.map +1 -0
- package/dist/components/scroll-region.d.ts +85 -0
- package/dist/components/scroll-region.d.ts.map +1 -0
- package/dist/components/scroll-region.js +574 -0
- package/dist/components/scroll-region.js.map +1 -0
- package/dist/components/select.d.ts +53 -0
- package/dist/components/select.d.ts.map +1 -0
- package/dist/components/select.js +33 -0
- package/dist/components/select.js.map +1 -0
- package/dist/components/table.d.ts +59 -0
- package/dist/components/table.d.ts.map +1 -0
- package/dist/components/table.js +20 -0
- package/dist/components/table.js.map +1 -0
- package/dist/components/tabs.d.ts +15 -0
- package/dist/components/tabs.d.ts.map +1 -0
- package/dist/components/tabs.js +13 -0
- package/dist/components/tabs.js.map +1 -0
- package/dist/components/tags.d.ts +46 -0
- package/dist/components/tags.d.ts.map +1 -0
- package/dist/components/tags.js +68 -0
- package/dist/components/tags.js.map +1 -0
- package/dist/components/tokens.css +392 -0
- package/dist/components/tokens.d.ts +15 -0
- package/dist/components/tokens.d.ts.map +1 -0
- package/dist/components/tokens.js +101 -0
- package/dist/components/tokens.js.map +1 -0
- package/dist/components/tool-picker.d.ts +24 -0
- package/dist/components/tool-picker.d.ts.map +1 -0
- package/dist/components/tool-picker.js +97 -0
- package/dist/components/tool-picker.js.map +1 -0
- package/dist/components/tooltip.d.ts +17 -0
- package/dist/components/tooltip.d.ts.map +1 -0
- package/dist/components/tooltip.js +18 -0
- package/dist/components/tooltip.js.map +1 -0
- package/dist/components/version-history-panel.d.ts +71 -0
- package/dist/components/version-history-panel.d.ts.map +1 -0
- package/dist/components/version-history-panel.js +148 -0
- package/dist/components/version-history-panel.js.map +1 -0
- package/dist/download.d.ts +7 -0
- package/dist/download.d.ts.map +1 -0
- package/dist/download.js +18 -0
- package/dist/download.js.map +1 -0
- package/dist/element-slot.d.ts +4 -0
- package/dist/element-slot.d.ts.map +1 -0
- package/dist/element-slot.js +20 -0
- package/dist/element-slot.js.map +1 -0
- package/dist/elicitation/ElicitationForm.d.ts +34 -0
- package/dist/elicitation/ElicitationForm.d.ts.map +1 -0
- package/dist/elicitation/ElicitationForm.js +24 -0
- package/dist/elicitation/ElicitationForm.js.map +1 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +15 -0
- package/dist/errors.js.map +1 -0
- package/dist/extension-combos.d.ts +13 -0
- package/dist/extension-combos.d.ts.map +1 -0
- package/dist/extension-combos.js +9 -0
- package/dist/extension-combos.js.map +1 -0
- package/dist/hooks/useApi.d.ts +1222 -0
- package/dist/hooks/useApi.d.ts.map +1 -0
- package/dist/hooks/useApi.js +22 -0
- package/dist/hooks/useApi.js.map +1 -0
- package/dist/hooks/useAuth.d.ts +13 -0
- package/dist/hooks/useAuth.d.ts.map +1 -0
- package/dist/hooks/useAuth.js +53 -0
- package/dist/hooks/useAuth.js.map +1 -0
- package/dist/hooks/useBreakpoint.d.ts +10 -0
- package/dist/hooks/useBreakpoint.d.ts.map +1 -0
- package/dist/hooks/useBreakpoint.js +66 -0
- package/dist/hooks/useBreakpoint.js.map +1 -0
- package/dist/hooks/useCapabilities.d.ts +88 -0
- package/dist/hooks/useCapabilities.d.ts.map +1 -0
- package/dist/hooks/useCapabilities.js +203 -0
- package/dist/hooks/useCapabilities.js.map +1 -0
- package/dist/hooks/useSse.d.ts +13 -0
- package/dist/hooks/useSse.d.ts.map +1 -0
- package/dist/hooks/useSse.js +298 -0
- package/dist/hooks/useSse.js.map +1 -0
- package/dist/hooks/useTheme.d.ts +20 -0
- package/dist/hooks/useTheme.d.ts.map +1 -0
- package/dist/hooks/useTheme.js +97 -0
- package/dist/hooks/useTheme.js.map +1 -0
- package/dist/hooks/useUnauthorized.d.ts +3 -0
- package/dist/hooks/useUnauthorized.d.ts.map +1 -0
- package/dist/hooks/useUnauthorized.js +18 -0
- package/dist/hooks/useUnauthorized.js.map +1 -0
- package/dist/host.d.ts +11 -0
- package/dist/host.d.ts.map +1 -0
- package/dist/host.js +10 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +118 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +91 -0
- package/dist/index.js.map +1 -0
- package/dist/navigation/context.d.ts +25 -0
- package/dist/navigation/context.d.ts.map +1 -0
- package/dist/navigation/context.js +45 -0
- package/dist/navigation/context.js.map +1 -0
- package/dist/navigation/index.d.ts +4 -0
- package/dist/navigation/index.d.ts.map +1 -0
- package/dist/navigation/index.js +2 -0
- package/dist/navigation/index.js.map +1 -0
- package/dist/navigation/types.d.ts +97 -0
- package/dist/navigation/types.d.ts.map +1 -0
- package/dist/navigation/types.js +16 -0
- package/dist/navigation/types.js.map +1 -0
- package/dist/plugin/host-state.d.ts +45 -0
- package/dist/plugin/host-state.d.ts.map +1 -0
- package/dist/plugin/host-state.js +62 -0
- package/dist/plugin/host-state.js.map +1 -0
- package/dist/plugin/registry.d.ts +35 -0
- package/dist/plugin/registry.d.ts.map +1 -0
- package/dist/plugin/registry.js +129 -0
- package/dist/plugin/registry.js.map +1 -0
- package/dist/plugin/types.d.ts +165 -0
- package/dist/plugin/types.d.ts.map +1 -0
- package/dist/plugin/types.js +2 -0
- package/dist/plugin/types.js.map +1 -0
- package/dist/plugin/version.d.ts +34 -0
- package/dist/plugin/version.d.ts.map +1 -0
- package/dist/plugin/version.js +37 -0
- package/dist/plugin/version.js.map +1 -0
- package/dist/query-keys.d.ts +31 -0
- package/dist/query-keys.d.ts.map +1 -0
- package/dist/query-keys.js +31 -0
- package/dist/query-keys.js.map +1 -0
- package/dist/schema-editor/SchemaEditor.d.ts +43 -0
- package/dist/schema-editor/SchemaEditor.d.ts.map +1 -0
- package/dist/schema-editor/SchemaEditor.js +106 -0
- package/dist/schema-editor/SchemaEditor.js.map +1 -0
- package/dist/schema-editor/index.d.ts +10 -0
- package/dist/schema-editor/index.d.ts.map +1 -0
- package/dist/schema-editor/index.js +8 -0
- package/dist/schema-editor/index.js.map +1 -0
- package/dist/schema-editor/lint.d.ts +37 -0
- package/dist/schema-editor/lint.d.ts.map +1 -0
- package/dist/schema-editor/lint.js +49 -0
- package/dist/schema-editor/lint.js.map +1 -0
- package/dist/schema-form/SchemaForm.d.ts +70 -0
- package/dist/schema-form/SchemaForm.d.ts.map +1 -0
- package/dist/schema-form/SchemaForm.js +13 -0
- package/dist/schema-form/SchemaForm.js.map +1 -0
- package/dist/schema-form/array-field.d.ts +20 -0
- package/dist/schema-form/array-field.d.ts.map +1 -0
- package/dist/schema-form/array-field.js +39 -0
- package/dist/schema-form/array-field.js.map +1 -0
- package/dist/schema-form/classify.d.ts +87 -0
- package/dist/schema-form/classify.d.ts.map +1 -0
- package/dist/schema-form/classify.js +199 -0
- package/dist/schema-form/classify.js.map +1 -0
- package/dist/schema-form/context.d.ts +4 -0
- package/dist/schema-form/context.d.ts.map +1 -0
- package/dist/schema-form/context.js +14 -0
- package/dist/schema-form/context.js.map +1 -0
- package/dist/schema-form/default-value.d.ts +8 -0
- package/dist/schema-form/default-value.d.ts.map +1 -0
- package/dist/schema-form/default-value.js +64 -0
- package/dist/schema-form/default-value.js.map +1 -0
- package/dist/schema-form/enum-field.d.ts +19 -0
- package/dist/schema-form/enum-field.d.ts.map +1 -0
- package/dist/schema-form/enum-field.js +26 -0
- package/dist/schema-form/enum-field.js.map +1 -0
- package/dist/schema-form/field-group.d.ts +9 -0
- package/dist/schema-form/field-group.d.ts.map +1 -0
- package/dist/schema-form/field-group.js +32 -0
- package/dist/schema-form/field-group.js.map +1 -0
- package/dist/schema-form/field-node.d.ts +24 -0
- package/dist/schema-form/field-node.d.ts.map +1 -0
- package/dist/schema-form/field-node.js +51 -0
- package/dist/schema-form/field-node.js.map +1 -0
- package/dist/schema-form/index.d.ts +13 -0
- package/dist/schema-form/index.d.ts.map +1 -0
- package/dist/schema-form/index.js +11 -0
- package/dist/schema-form/index.js.map +1 -0
- package/dist/schema-form/media-field.d.ts +24 -0
- package/dist/schema-form/media-field.d.ts.map +1 -0
- package/dist/schema-form/media-field.js +169 -0
- package/dist/schema-form/media-field.js.map +1 -0
- package/dist/schema-form/media.d.ts +40 -0
- package/dist/schema-form/media.d.ts.map +1 -0
- package/dist/schema-form/media.js +65 -0
- package/dist/schema-form/media.js.map +1 -0
- package/dist/schema-form/object-fields.d.ts +20 -0
- package/dist/schema-form/object-fields.d.ts.map +1 -0
- package/dist/schema-form/object-fields.js +24 -0
- package/dist/schema-form/object-fields.js.map +1 -0
- package/dist/schema-form/resolve.d.ts +41 -0
- package/dist/schema-form/resolve.d.ts.map +1 -0
- package/dist/schema-form/resolve.js +102 -0
- package/dist/schema-form/resolve.js.map +1 -0
- package/dist/schema-form/string-field.d.ts +20 -0
- package/dist/schema-form/string-field.d.ts.map +1 -0
- package/dist/schema-form/string-field.js +68 -0
- package/dist/schema-form/string-field.js.map +1 -0
- package/dist/schema-form/styles.d.ts +18 -0
- package/dist/schema-form/styles.d.ts.map +1 -0
- package/dist/schema-form/styles.js +18 -0
- package/dist/schema-form/styles.js.map +1 -0
- package/dist/schema-form/types.d.ts +67 -0
- package/dist/schema-form/types.d.ts.map +1 -0
- package/dist/schema-form/types.js +10 -0
- package/dist/schema-form/types.js.map +1 -0
- package/dist/schema-form/union-field.d.ts +24 -0
- package/dist/schema-form/union-field.d.ts.map +1 -0
- package/dist/schema-form/union-field.js +55 -0
- package/dist/schema-form/union-field.js.map +1 -0
- package/dist/schema-form/union.d.ts +4 -0
- package/dist/schema-form/union.d.ts.map +1 -0
- package/dist/schema-form/union.js +68 -0
- package/dist/schema-form/union.js.map +1 -0
- package/dist/schema-form/unsupported-notice.d.ts +18 -0
- package/dist/schema-form/unsupported-notice.d.ts.map +1 -0
- package/dist/schema-form/unsupported-notice.js +8 -0
- package/dist/schema-form/unsupported-notice.js.map +1 -0
- package/dist/schema-form/validate.d.ts +18 -0
- package/dist/schema-form/validate.d.ts.map +1 -0
- package/dist/schema-form/validate.js +162 -0
- package/dist/schema-form/validate.js.map +1 -0
- package/dist/structured-output/StructuredOutput.d.ts +27 -0
- package/dist/structured-output/StructuredOutput.d.ts.map +1 -0
- package/dist/structured-output/StructuredOutput.js +41 -0
- package/dist/structured-output/StructuredOutput.js.map +1 -0
- package/dist/testing.d.ts +80 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +207 -0
- package/dist/testing.js.map +1 -0
- package/package.json +101 -0
- package/src/components/badge.tsx +31 -0
- package/src/components/checkbox.tsx +93 -0
- package/src/components/code-block.tsx +37 -0
- package/src/components/completion-input.tsx +137 -0
- package/src/components/components.css +2212 -0
- package/src/components/confirm-dialog.tsx +77 -0
- package/src/components/control-styles.ts +14 -0
- package/src/components/copy-field.tsx +178 -0
- package/src/components/dialog.tsx +76 -0
- package/src/components/drawer.tsx +83 -0
- package/src/components/error-boundary.tsx +56 -0
- package/src/components/extension-combo-builder.tsx +305 -0
- package/src/components/extension-grouping.ts +116 -0
- package/src/components/extension-picker.tsx +97 -0
- package/src/components/external-link.tsx +40 -0
- package/src/components/field.tsx +169 -0
- package/src/components/fleet-report.tsx +136 -0
- package/src/components/fonts.css +22 -0
- package/src/components/icons.tsx +504 -0
- package/src/components/inputs.tsx +125 -0
- package/src/components/json-diff.tsx +192 -0
- package/src/components/json-tree.tsx +129 -0
- package/src/components/modal-focus.ts +150 -0
- package/src/components/page-header.tsx +107 -0
- package/src/components/primitives.tsx +489 -0
- package/src/components/progress-bar.tsx +100 -0
- package/src/components/radio-group.tsx +160 -0
- package/src/components/reveal-input.tsx +87 -0
- package/src/components/scroll-region.tsx +703 -0
- package/src/components/select.tsx +149 -0
- package/src/components/table.tsx +94 -0
- package/src/components/tabs.tsx +51 -0
- package/src/components/tags.tsx +137 -0
- package/src/components/tokens.css +392 -0
- package/src/components/tokens.ts +102 -0
- package/src/components/tool-picker.tsx +175 -0
- package/src/components/tooltip.tsx +46 -0
- package/src/components/version-history-panel.tsx +407 -0
- package/src/css.d.ts +2 -0
- package/src/download.ts +17 -0
- package/src/element-slot.ts +22 -0
- package/src/elicitation/ElicitationForm.tsx +87 -0
- package/src/errors.ts +12 -0
- package/src/extension-combos.ts +18 -0
- package/src/hooks/useApi.ts +27 -0
- package/src/hooks/useAuth.ts +67 -0
- package/src/hooks/useBreakpoint.ts +78 -0
- package/src/hooks/useCapabilities.ts +242 -0
- package/src/hooks/useSse.ts +314 -0
- package/src/hooks/useTheme.ts +133 -0
- package/src/hooks/useUnauthorized.ts +20 -0
- package/src/host.ts +15 -0
- package/src/index.ts +267 -0
- package/src/navigation/context.tsx +90 -0
- package/src/navigation/index.ts +9 -0
- package/src/navigation/types.ts +90 -0
- package/src/plugin/host-state.ts +93 -0
- package/src/plugin/registry.ts +174 -0
- package/src/plugin/types.ts +178 -0
- package/src/plugin/version.ts +44 -0
- package/src/query-keys.ts +34 -0
- package/src/schema-editor/SchemaEditor.tsx +186 -0
- package/src/schema-editor/index.ts +9 -0
- package/src/schema-editor/lint.ts +70 -0
- package/src/schema-form/SchemaForm.tsx +107 -0
- package/src/schema-form/array-field.tsx +110 -0
- package/src/schema-form/classify.ts +302 -0
- package/src/schema-form/context.ts +17 -0
- package/src/schema-form/default-value.ts +65 -0
- package/src/schema-form/enum-field.tsx +65 -0
- package/src/schema-form/field-group.tsx +71 -0
- package/src/schema-form/field-node.tsx +161 -0
- package/src/schema-form/index.ts +12 -0
- package/src/schema-form/media-field.tsx +281 -0
- package/src/schema-form/media.ts +69 -0
- package/src/schema-form/object-fields.tsx +74 -0
- package/src/schema-form/resolve.ts +114 -0
- package/src/schema-form/string-field.tsx +161 -0
- package/src/schema-form/styles.ts +20 -0
- package/src/schema-form/types.ts +71 -0
- package/src/schema-form/union-field.tsx +122 -0
- package/src/schema-form/union.ts +78 -0
- package/src/schema-form/unsupported-notice.tsx +39 -0
- package/src/schema-form/validate.ts +183 -0
- package/src/structured-output/StructuredOutput.tsx +81 -0
- package/src/testing.ts +235 -0
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The enum field renderer. A short enum (up to {@link RADIO_MAX_OPTIONS}
|
|
3
|
+
* options) renders as a radio group; a longer one renders as a select. Options
|
|
4
|
+
* are keyed by index so a non-string enum value round-trips through the string
|
|
5
|
+
* control.
|
|
6
|
+
*/
|
|
7
|
+
import type { ReactNode } from 'react';
|
|
8
|
+
|
|
9
|
+
import { Field } from '../components/field';
|
|
10
|
+
import { RadioGroup } from '../components/radio-group';
|
|
11
|
+
import { Select } from '../components/select';
|
|
12
|
+
import type { FieldModel } from './classify';
|
|
13
|
+
|
|
14
|
+
const RADIO_MAX_OPTIONS = 3;
|
|
15
|
+
|
|
16
|
+
export function EnumField({
|
|
17
|
+
heading,
|
|
18
|
+
description,
|
|
19
|
+
error,
|
|
20
|
+
model,
|
|
21
|
+
value,
|
|
22
|
+
onChange,
|
|
23
|
+
}: {
|
|
24
|
+
heading: string;
|
|
25
|
+
description: string | undefined;
|
|
26
|
+
error: string | undefined;
|
|
27
|
+
model: Extract<FieldModel, { kind: 'enum' }>;
|
|
28
|
+
value: unknown;
|
|
29
|
+
onChange: (value: unknown) => void;
|
|
30
|
+
}): ReactNode {
|
|
31
|
+
const options = model.options.map((option, index) => ({
|
|
32
|
+
key: String(index),
|
|
33
|
+
label: option.label,
|
|
34
|
+
value: option.value,
|
|
35
|
+
}));
|
|
36
|
+
const selected = options.find((option) => option.value === value);
|
|
37
|
+
// Always a defined string so the control stays controlled from first render
|
|
38
|
+
// (an `undefined` → string transition warns about uncontrolled→controlled).
|
|
39
|
+
const selectedKey = selected?.key ?? '';
|
|
40
|
+
const emit = (key: string): void => {
|
|
41
|
+
const match = options.find((option) => option.key === key);
|
|
42
|
+
if (match !== undefined) onChange(match.value);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (options.length <= RADIO_MAX_OPTIONS) {
|
|
46
|
+
return (
|
|
47
|
+
<Field label={heading} description={description} error={error} group>
|
|
48
|
+
<RadioGroup
|
|
49
|
+
options={options.map((option) => ({ value: option.key, label: option.label }))}
|
|
50
|
+
value={selectedKey}
|
|
51
|
+
onValueChange={emit}
|
|
52
|
+
/>
|
|
53
|
+
</Field>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return (
|
|
57
|
+
<Field label={heading} description={description} error={error}>
|
|
58
|
+
<Select
|
|
59
|
+
options={options.map((option) => ({ value: option.key, label: option.label }))}
|
|
60
|
+
value={selectedKey}
|
|
61
|
+
onValueChange={emit}
|
|
62
|
+
/>
|
|
63
|
+
</Field>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The wrapper around a nested group of fields (object, array, or union). At the
|
|
3
|
+
* form root it lays children out in a plain stack; below the root it renders a
|
|
4
|
+
* heading, an optional description, an optional error — the ERROR mark beside
|
|
5
|
+
* the message, never a hue on its own — and the nested group surface.
|
|
6
|
+
*/
|
|
7
|
+
import { useId } from 'react';
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
|
|
10
|
+
import { XCircleIcon } from '../components/icons';
|
|
11
|
+
import { groupClass, groupHeaderClass, stackClass } from './styles';
|
|
12
|
+
|
|
13
|
+
export function FieldGroup({
|
|
14
|
+
heading,
|
|
15
|
+
description,
|
|
16
|
+
error,
|
|
17
|
+
atRoot,
|
|
18
|
+
children,
|
|
19
|
+
}: {
|
|
20
|
+
heading: string;
|
|
21
|
+
description: string | undefined;
|
|
22
|
+
error: string | undefined;
|
|
23
|
+
atRoot: boolean;
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}): ReactNode {
|
|
26
|
+
const baseId = useId();
|
|
27
|
+
const headingId = `${baseId}-heading`;
|
|
28
|
+
const descriptionId = `${baseId}-desc`;
|
|
29
|
+
const errorId = `${baseId}-err`;
|
|
30
|
+
|
|
31
|
+
if (atRoot) {
|
|
32
|
+
return <div className={stackClass}>{children}</div>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// The group container carries the ROLE, the name and the description. Without
|
|
36
|
+
// them the heading was a `<span>` — not a heading either — and the description
|
|
37
|
+
// and the validation error were orphaned text: entering a nested object in a
|
|
38
|
+
// schema form announced the controls with no indication of which group they
|
|
39
|
+
// belonged to. It is the same "named by construction" shape `Field group`
|
|
40
|
+
// uses, for the same reason.
|
|
41
|
+
const describedByIds: string[] = [];
|
|
42
|
+
if (description !== undefined) describedByIds.push(descriptionId);
|
|
43
|
+
if (error !== undefined) describedByIds.push(errorId);
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className={groupHeaderClass}>
|
|
47
|
+
<span id={headingId} className="tai-field-label">
|
|
48
|
+
{heading}
|
|
49
|
+
</span>
|
|
50
|
+
{description !== undefined ? (
|
|
51
|
+
<span id={descriptionId} className="tai-field-hint">
|
|
52
|
+
{description}
|
|
53
|
+
</span>
|
|
54
|
+
) : null}
|
|
55
|
+
{error !== undefined ? (
|
|
56
|
+
<span id={errorId} role="alert" className="tai-field-error">
|
|
57
|
+
<XCircleIcon />
|
|
58
|
+
{error}
|
|
59
|
+
</span>
|
|
60
|
+
) : null}
|
|
61
|
+
<div
|
|
62
|
+
role="group"
|
|
63
|
+
aria-labelledby={headingId}
|
|
64
|
+
aria-describedby={describedByIds.length > 0 ? describedByIds.join(' ') : undefined}
|
|
65
|
+
className={groupClass}
|
|
66
|
+
>
|
|
67
|
+
{children}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recursive dispatcher: classifies a single schema node and renders the
|
|
3
|
+
* matching field component for its model kind (const/number/boolean render
|
|
4
|
+
* inline; every other kind delegates to a dedicated field component). Object,
|
|
5
|
+
* array, and union renderers recurse back through here for their children.
|
|
6
|
+
*/
|
|
7
|
+
import type { ReactNode } from 'react';
|
|
8
|
+
|
|
9
|
+
import { Checkbox } from '../components/checkbox';
|
|
10
|
+
import { Field } from '../components/field';
|
|
11
|
+
import { NumberInput, TextInput } from '../components/inputs';
|
|
12
|
+
import { ArrayField } from './array-field';
|
|
13
|
+
import { classifySchema } from './classify';
|
|
14
|
+
import { EnumField } from './enum-field';
|
|
15
|
+
import { FieldGroup } from './field-group';
|
|
16
|
+
import { ObjectFields } from './object-fields';
|
|
17
|
+
import { scalarLabel } from './resolve';
|
|
18
|
+
import { StringField } from './string-field';
|
|
19
|
+
import { UnionField } from './union-field';
|
|
20
|
+
import { UnsupportedNotice } from './unsupported-notice';
|
|
21
|
+
import type { JsonSchema, SchemaFormErrors } from './types';
|
|
22
|
+
|
|
23
|
+
interface FieldNodeProps {
|
|
24
|
+
readonly schema: JsonSchema;
|
|
25
|
+
readonly root: JsonSchema;
|
|
26
|
+
readonly value: unknown;
|
|
27
|
+
readonly onChange: (value: unknown) => void;
|
|
28
|
+
readonly path: string;
|
|
29
|
+
/** The label to show — a property key, array-item caption, or `undefined` at root. */
|
|
30
|
+
readonly label: string | undefined;
|
|
31
|
+
readonly required: boolean;
|
|
32
|
+
readonly errors: SchemaFormErrors | undefined;
|
|
33
|
+
readonly idPrefix: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function labelText(explicit: string | undefined, label: string | undefined): string {
|
|
37
|
+
return explicit ?? label ?? 'Value';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A single schema node, dispatched on its classified model kind. */
|
|
41
|
+
export function FieldNode(props: FieldNodeProps): ReactNode {
|
|
42
|
+
const { schema, root, value, onChange, path, label, required, errors, idPrefix } = props;
|
|
43
|
+
const classified = classifySchema(schema, root);
|
|
44
|
+
const heading = labelText(classified.title, label);
|
|
45
|
+
const description = classified.description;
|
|
46
|
+
const error = errors?.[path];
|
|
47
|
+
const model = classified.model;
|
|
48
|
+
|
|
49
|
+
switch (model.kind) {
|
|
50
|
+
case 'unsupported':
|
|
51
|
+
return (
|
|
52
|
+
<UnsupportedNotice
|
|
53
|
+
heading={heading}
|
|
54
|
+
reason={model.reason}
|
|
55
|
+
description={description}
|
|
56
|
+
error={error}
|
|
57
|
+
/>
|
|
58
|
+
);
|
|
59
|
+
case 'const':
|
|
60
|
+
return (
|
|
61
|
+
<Field label={heading} description={description} error={error}>
|
|
62
|
+
<TextInput value={scalarLabel(model.value)} readOnly disabled />
|
|
63
|
+
</Field>
|
|
64
|
+
);
|
|
65
|
+
case 'enum':
|
|
66
|
+
return (
|
|
67
|
+
<EnumField
|
|
68
|
+
heading={heading}
|
|
69
|
+
description={description}
|
|
70
|
+
error={error}
|
|
71
|
+
model={model}
|
|
72
|
+
value={value}
|
|
73
|
+
onChange={onChange}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
case 'string':
|
|
77
|
+
return (
|
|
78
|
+
<StringField
|
|
79
|
+
heading={heading}
|
|
80
|
+
description={description}
|
|
81
|
+
error={error}
|
|
82
|
+
format={model.format}
|
|
83
|
+
media={model.media}
|
|
84
|
+
argName={path}
|
|
85
|
+
value={value}
|
|
86
|
+
required={required}
|
|
87
|
+
onChange={onChange}
|
|
88
|
+
/>
|
|
89
|
+
);
|
|
90
|
+
case 'number':
|
|
91
|
+
return (
|
|
92
|
+
<Field label={heading} description={description} error={error}>
|
|
93
|
+
<NumberInput
|
|
94
|
+
step={model.integer ? '1' : 'any'}
|
|
95
|
+
value={typeof value === 'number' ? value : ''}
|
|
96
|
+
onChange={(event) => {
|
|
97
|
+
const raw = event.target.value;
|
|
98
|
+
onChange(raw === '' ? undefined : Number(raw));
|
|
99
|
+
}}
|
|
100
|
+
/>
|
|
101
|
+
</Field>
|
|
102
|
+
);
|
|
103
|
+
case 'boolean':
|
|
104
|
+
return (
|
|
105
|
+
<Field label={heading} description={description} error={error}>
|
|
106
|
+
<Checkbox
|
|
107
|
+
checked={value === true}
|
|
108
|
+
onCheckedChange={(checked) => {
|
|
109
|
+
onChange(checked);
|
|
110
|
+
}}
|
|
111
|
+
/>
|
|
112
|
+
</Field>
|
|
113
|
+
);
|
|
114
|
+
case 'array':
|
|
115
|
+
return (
|
|
116
|
+
<ArrayField
|
|
117
|
+
heading={heading}
|
|
118
|
+
description={description}
|
|
119
|
+
error={error}
|
|
120
|
+
items={model.items}
|
|
121
|
+
root={root}
|
|
122
|
+
value={value}
|
|
123
|
+
onChange={onChange}
|
|
124
|
+
path={path}
|
|
125
|
+
errors={errors}
|
|
126
|
+
idPrefix={idPrefix}
|
|
127
|
+
/>
|
|
128
|
+
);
|
|
129
|
+
case 'object':
|
|
130
|
+
return (
|
|
131
|
+
<FieldGroup heading={heading} description={description} error={error} atRoot={path === ''}>
|
|
132
|
+
<ObjectFields
|
|
133
|
+
properties={model.properties}
|
|
134
|
+
requiredKeys={model.required}
|
|
135
|
+
root={root}
|
|
136
|
+
value={value}
|
|
137
|
+
onChange={onChange}
|
|
138
|
+
path={path}
|
|
139
|
+
errors={errors}
|
|
140
|
+
idPrefix={idPrefix}
|
|
141
|
+
skip={undefined}
|
|
142
|
+
/>
|
|
143
|
+
</FieldGroup>
|
|
144
|
+
);
|
|
145
|
+
case 'union':
|
|
146
|
+
return (
|
|
147
|
+
<UnionField
|
|
148
|
+
heading={heading}
|
|
149
|
+
description={description}
|
|
150
|
+
error={error}
|
|
151
|
+
model={model}
|
|
152
|
+
root={root}
|
|
153
|
+
value={value}
|
|
154
|
+
onChange={onChange}
|
|
155
|
+
path={path}
|
|
156
|
+
errors={errors}
|
|
157
|
+
idPrefix={idPrefix}
|
|
158
|
+
/>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-driven forms — the load-bearing auto-form renderer, reused by
|
|
3
|
+
* the tools and interactions features. Public surface: the `SchemaForm`
|
|
4
|
+
* component plus the value/validation helpers a caller needs to seed, validate,
|
|
5
|
+
* and submit.
|
|
6
|
+
*/
|
|
7
|
+
export { SchemaForm } from './SchemaForm';
|
|
8
|
+
export type { SchemaFormProps, CompletionProvider } from './SchemaForm';
|
|
9
|
+
export { defaultValueForSchema } from './default-value';
|
|
10
|
+
export { validateAgainstSchema } from './validate';
|
|
11
|
+
export { resolveRef } from './resolve';
|
|
12
|
+
export type { JsonSchema, JsonSchemaType, Discriminator, SchemaFormErrors } from './types';
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The media-upload string field: a drag-drop surface plus a paste fallback that
|
|
3
|
+
* reads the chosen file client-side and emits the encoded string the schema
|
|
4
|
+
* expects (a base64 body or a full `data:` URL). Every input path enforces the
|
|
5
|
+
* effective byte cap and the declared MIME, rejecting an over-cap value or a
|
|
6
|
+
* mismatched type LOUDLY — never silently truncated or accepted.
|
|
7
|
+
*/
|
|
8
|
+
import type { ReactNode } from 'react';
|
|
9
|
+
import { useContext, useId, useState } from 'react';
|
|
10
|
+
|
|
11
|
+
import { Badge } from '../components/badge';
|
|
12
|
+
import { XCircleIcon } from '../components/icons';
|
|
13
|
+
import { Button } from '../components/primitives';
|
|
14
|
+
import { Field, useFieldControl, type FieldControlProps } from '../components/field';
|
|
15
|
+
import { TextInput } from '../components/inputs';
|
|
16
|
+
import type { MediaUpload } from './classify';
|
|
17
|
+
import { MaxUploadBytesContext } from './context';
|
|
18
|
+
import { decodedByteSize, effectiveMaxBytes, overCapMessage } from './media';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Add the upload error this module owns to the enclosing `Field`'s wiring.
|
|
22
|
+
*
|
|
23
|
+
* The upload error is raised by the input surfaces themselves rather than by the
|
|
24
|
+
* schema, so `Field` cannot see it: without this the message was announced by
|
|
25
|
+
* nothing and neither surface reported itself invalid. Used only where the
|
|
26
|
+
* element is a bare `<input>`; the SDK's `TextInput` composes the field wiring
|
|
27
|
+
* with the caller's own attributes itself, so the paste fallback states only the
|
|
28
|
+
* error's id and lets it do the joining.
|
|
29
|
+
*/
|
|
30
|
+
function withUploadError(
|
|
31
|
+
field: FieldControlProps,
|
|
32
|
+
uploadErrorId: string | undefined,
|
|
33
|
+
): FieldControlProps {
|
|
34
|
+
if (uploadErrorId === undefined) return field;
|
|
35
|
+
const describedBy = field['aria-describedby'];
|
|
36
|
+
return {
|
|
37
|
+
...field,
|
|
38
|
+
'aria-describedby':
|
|
39
|
+
describedBy === undefined ? uploadErrorId : `${describedBy} ${uploadErrorId}`,
|
|
40
|
+
'aria-invalid': true,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The drag-drop surface + file input. Split out so its `useFieldControl` call
|
|
46
|
+
* runs INSIDE the enclosing {@link Field}, wiring the input's id/aria to the
|
|
47
|
+
* field label (a hook at {@link MediaField}'s top level would read the outer,
|
|
48
|
+
* label-less context instead).
|
|
49
|
+
*/
|
|
50
|
+
function UploadDropZone({
|
|
51
|
+
accept,
|
|
52
|
+
uploadErrorId,
|
|
53
|
+
onFile,
|
|
54
|
+
}: {
|
|
55
|
+
accept: string | undefined;
|
|
56
|
+
/** Set while an upload error stands, so the input points at it and reads invalid. */
|
|
57
|
+
uploadErrorId: string | undefined;
|
|
58
|
+
onFile: (file: File | undefined) => void;
|
|
59
|
+
}): ReactNode {
|
|
60
|
+
const field = withUploadError(useFieldControl(), uploadErrorId);
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
className="tai-card tai-stack tai-stack-2"
|
|
64
|
+
// The dashed edge is the drop target's ONLY boundary, so it takes the
|
|
65
|
+
// contrast-safe control border rather than the card's decorative one; the
|
|
66
|
+
// card class still owns the ground, the radius and the padding.
|
|
67
|
+
style={{ borderStyle: 'dashed', borderColor: 'var(--tai-color-control-border)' }}
|
|
68
|
+
onDragOver={(event) => {
|
|
69
|
+
event.preventDefault();
|
|
70
|
+
}}
|
|
71
|
+
onDrop={(event) => {
|
|
72
|
+
event.preventDefault();
|
|
73
|
+
onFile(event.dataTransfer.files[0]);
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
<input
|
|
77
|
+
{...field}
|
|
78
|
+
type="file"
|
|
79
|
+
accept={accept}
|
|
80
|
+
onChange={(event) => {
|
|
81
|
+
onFile(event.target.files?.[0]);
|
|
82
|
+
// Reset so re-picking the same file fires `change` again.
|
|
83
|
+
event.target.value = '';
|
|
84
|
+
}}
|
|
85
|
+
/>
|
|
86
|
+
<span className="tai-field-hint">Drag & drop a file here, or choose one above.</span>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A string field whose schema opts into a file upload. Reads the chosen file
|
|
93
|
+
* client-side and emits the encoded string the schema expects (a base64 body or
|
|
94
|
+
* a full `data:` URL) — bounded by a size cap and the declared MIME, both
|
|
95
|
+
* rejected LOUDLY. A text fallback (paste a URL/base64) stays available.
|
|
96
|
+
*/
|
|
97
|
+
export function MediaField({
|
|
98
|
+
heading,
|
|
99
|
+
description,
|
|
100
|
+
error,
|
|
101
|
+
media,
|
|
102
|
+
value,
|
|
103
|
+
onChange,
|
|
104
|
+
}: {
|
|
105
|
+
heading: string;
|
|
106
|
+
description: string | undefined;
|
|
107
|
+
error: string | undefined;
|
|
108
|
+
media: MediaUpload;
|
|
109
|
+
value: string;
|
|
110
|
+
onChange: (value: string) => void;
|
|
111
|
+
}): ReactNode {
|
|
112
|
+
const pasteId = useId();
|
|
113
|
+
const uploadErrorId = useId();
|
|
114
|
+
const defaultMax = useContext(MaxUploadBytesContext);
|
|
115
|
+
const maxBytes = effectiveMaxBytes(media.maxBytes, defaultMax);
|
|
116
|
+
const [uploadError, setUploadError] = useState<string | undefined>(undefined);
|
|
117
|
+
const [attached, setAttached] = useState<
|
|
118
|
+
{ name: string; previewUrl: string; value: string } | undefined
|
|
119
|
+
>(undefined);
|
|
120
|
+
|
|
121
|
+
const accept = (file: File | undefined): void => {
|
|
122
|
+
if (file === undefined) return;
|
|
123
|
+
void readMediaFile(file, media, maxBytes).then(
|
|
124
|
+
({ value: encoded, previewUrl }) => {
|
|
125
|
+
setUploadError(undefined);
|
|
126
|
+
setAttached({ name: file.name, previewUrl, value: encoded });
|
|
127
|
+
onChange(encoded);
|
|
128
|
+
},
|
|
129
|
+
(reason: unknown) => {
|
|
130
|
+
// Reject loudly; keep any previously accepted value untouched.
|
|
131
|
+
setUploadError(reason instanceof Error ? reason.message : String(reason));
|
|
132
|
+
},
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const clear = (): void => {
|
|
137
|
+
setUploadError(undefined);
|
|
138
|
+
setAttached(undefined);
|
|
139
|
+
onChange('');
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// The just-picked file's chip/preview applies ONLY while it still matches the
|
|
143
|
+
// current field value; if the value is reset or replaced externally (an edit
|
|
144
|
+
// loads a different record, or the form resets after submit), fall back to a
|
|
145
|
+
// value-derived display so a stale filename/thumbnail can never linger.
|
|
146
|
+
const activeAttached = attached?.value === value ? attached : undefined;
|
|
147
|
+
// Prefer the just-read preview; on an edit with a pre-existing data-url value,
|
|
148
|
+
// reconstruct the preview from the value itself.
|
|
149
|
+
const previewUrl =
|
|
150
|
+
activeAttached?.previewUrl ??
|
|
151
|
+
(media.encoding === 'data-url' && value.startsWith('data:') ? value : undefined);
|
|
152
|
+
const isImage =
|
|
153
|
+
media.mediaType?.startsWith('image/') === true || previewUrl?.startsWith('data:image') === true;
|
|
154
|
+
const fileName = activeAttached?.name ?? (value !== '' ? 'Attached file' : undefined);
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<Field label={heading} description={description} error={error}>
|
|
158
|
+
<UploadDropZone
|
|
159
|
+
accept={media.mediaType}
|
|
160
|
+
uploadErrorId={uploadError === undefined ? undefined : uploadErrorId}
|
|
161
|
+
onFile={accept}
|
|
162
|
+
/>
|
|
163
|
+
|
|
164
|
+
{fileName !== undefined ? (
|
|
165
|
+
<div className="tai-row">
|
|
166
|
+
{isImage && previewUrl !== undefined ? (
|
|
167
|
+
<img
|
|
168
|
+
src={previewUrl}
|
|
169
|
+
alt={fileName}
|
|
170
|
+
// A thumbnail is per-instance geometry: it is capped to a fixed
|
|
171
|
+
// height and can never exceed the width it is given.
|
|
172
|
+
style={{
|
|
173
|
+
maxWidth: '100%',
|
|
174
|
+
maxHeight: '6rem',
|
|
175
|
+
borderRadius: 'var(--tai-radius-md)',
|
|
176
|
+
border: '1px solid var(--tai-color-control-border)',
|
|
177
|
+
}}
|
|
178
|
+
/>
|
|
179
|
+
) : (
|
|
180
|
+
<Badge>{fileName}</Badge>
|
|
181
|
+
)}
|
|
182
|
+
<Button type="button" variant="secondary" onClick={clear}>
|
|
183
|
+
Remove
|
|
184
|
+
</Button>
|
|
185
|
+
</div>
|
|
186
|
+
) : null}
|
|
187
|
+
|
|
188
|
+
{uploadError !== undefined ? (
|
|
189
|
+
<span id={uploadErrorId} role="alert" className="tai-field-error">
|
|
190
|
+
<XCircleIcon />
|
|
191
|
+
{uploadError}
|
|
192
|
+
</span>
|
|
193
|
+
) : null}
|
|
194
|
+
|
|
195
|
+
<label htmlFor={pasteId} className="tai-field">
|
|
196
|
+
<span className="tai-field-hint">Or paste a value</span>
|
|
197
|
+
<TextInput
|
|
198
|
+
id={pasteId}
|
|
199
|
+
type="text"
|
|
200
|
+
// Only the upload error's id: `TextInput` joins it with the enclosing
|
|
201
|
+
// Field's own description and error IDREFs rather than replacing them.
|
|
202
|
+
aria-describedby={uploadError === undefined ? undefined : uploadErrorId}
|
|
203
|
+
aria-invalid={uploadError === undefined ? undefined : true}
|
|
204
|
+
value={value}
|
|
205
|
+
onChange={(event) => {
|
|
206
|
+
const next = event.target.value;
|
|
207
|
+
// The same effective cap the picker enforces, measured on the pasted
|
|
208
|
+
// string's DECODED byte size. Reject over-cap LOUDLY; never accept or
|
|
209
|
+
// truncate — the field keeps its prior value.
|
|
210
|
+
const size = decodedByteSize(next);
|
|
211
|
+
if (size > maxBytes) {
|
|
212
|
+
setUploadError(overCapMessage('The pasted value', size, maxBytes));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
setUploadError(undefined);
|
|
216
|
+
setAttached(undefined);
|
|
217
|
+
onChange(next);
|
|
218
|
+
}}
|
|
219
|
+
/>
|
|
220
|
+
</label>
|
|
221
|
+
</Field>
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Compare a file's MIME against a `contentMediaType` pattern (`image/*` etc.). */
|
|
226
|
+
function matchesMediaType(fileType: string, pattern: string): boolean {
|
|
227
|
+
// An empty file type is genuinely unknown (jsdom / OS gaps) — the server still
|
|
228
|
+
// validates, so we do not fabricate a mismatch here; only a KNOWN wrong type
|
|
229
|
+
// is rejected.
|
|
230
|
+
if (fileType === '') return true;
|
|
231
|
+
const [patType, patSub] = pattern.toLowerCase().split('/');
|
|
232
|
+
const [fileMain, fileSub] = fileType.toLowerCase().split('/');
|
|
233
|
+
if (patType !== fileMain) return false;
|
|
234
|
+
return patSub === '*' || patSub === fileSub;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Validate a chosen file against the media contract and read it into the encoded
|
|
239
|
+
* string the schema expects. Rejects an over-cap file or a MIME that does not
|
|
240
|
+
* match `contentMediaType` — never truncates, never silently accepts.
|
|
241
|
+
*/
|
|
242
|
+
async function readMediaFile(
|
|
243
|
+
file: File,
|
|
244
|
+
media: MediaUpload,
|
|
245
|
+
maxBytes: number,
|
|
246
|
+
): Promise<{ value: string; previewUrl: string }> {
|
|
247
|
+
if (file.size > maxBytes) {
|
|
248
|
+
throw new Error(overCapMessage(`"${file.name}"`, file.size, maxBytes));
|
|
249
|
+
}
|
|
250
|
+
if (media.mediaType !== undefined && !matchesMediaType(file.type, media.mediaType)) {
|
|
251
|
+
throw new Error(
|
|
252
|
+
`"${file.name}" is ${file.type === '' ? 'an unknown type' : file.type}, which does not match the required ${media.mediaType}.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
const dataUrl = await readAsDataUrl(file);
|
|
256
|
+
const comma = dataUrl.indexOf(',');
|
|
257
|
+
if (comma < 0) {
|
|
258
|
+
throw new Error(`Could not read "${file.name}" as an encoded value.`);
|
|
259
|
+
}
|
|
260
|
+
const body = dataUrl.slice(comma + 1);
|
|
261
|
+
return { value: media.encoding === 'data-url' ? dataUrl : body, previewUrl: dataUrl };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function readAsDataUrl(file: File): Promise<string> {
|
|
265
|
+
return new Promise((resolve, reject) => {
|
|
266
|
+
const reader = new FileReader();
|
|
267
|
+
reader.onload = () => {
|
|
268
|
+
// A successful read must yield the data-url string; anything else is a real
|
|
269
|
+
// read anomaly and rejects loudly rather than emitting an empty value.
|
|
270
|
+
if (typeof reader.result === 'string') {
|
|
271
|
+
resolve(reader.result);
|
|
272
|
+
} else {
|
|
273
|
+
reject(new Error(`Failed to read "${file.name}" as a data URL.`));
|
|
274
|
+
}
|
|
275
|
+
};
|
|
276
|
+
reader.onerror = () => {
|
|
277
|
+
reject(reader.error ?? new Error(`Failed to read "${file.name}".`));
|
|
278
|
+
};
|
|
279
|
+
reader.readAsDataURL(file);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared byte-cap logic for media-upload string fields, used by EVERY path that
|
|
3
|
+
* can set a media field's value: the file picker, the "paste a value" fallback,
|
|
4
|
+
* and the pre-submit validation pass. Centralised here so the effective cap and
|
|
5
|
+
* the decoded byte size are computed IDENTICALLY on all three paths.
|
|
6
|
+
*
|
|
7
|
+
* `contentMaxBytes` is a NONSTANDARD JSON-Schema extension keyword — a standard
|
|
8
|
+
* JSON-Schema validator ignores it. These client-side caps are UX guards that
|
|
9
|
+
* fail LOUDLY and early on the obvious over-cap input; they are NOT a security
|
|
10
|
+
* boundary. The SERVER MUST independently validate the decoded size of any
|
|
11
|
+
* uploaded content.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Default cap for a media-upload field when neither the schema's `contentMaxBytes`
|
|
16
|
+
* nor the host's `maxUploadBytes` prop pins one. 10 MiB comfortably fits a typical
|
|
17
|
+
* image or PDF while still rejecting a runaway upload loudly.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_MAX_UPLOAD_BYTES = 10 * 1024 * 1024;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolve the effective byte cap for a media field. Precedence: the schema's
|
|
23
|
+
* per-field `contentMaxBytes` wins (the only channel by which the schema-authoring
|
|
24
|
+
* party states a per-field cap), else the host's `maxUploadBytes` default, else
|
|
25
|
+
* {@link DEFAULT_MAX_UPLOAD_BYTES}.
|
|
26
|
+
*/
|
|
27
|
+
export function effectiveMaxBytes(
|
|
28
|
+
fieldCap: number | undefined,
|
|
29
|
+
propCap: number | undefined,
|
|
30
|
+
): number {
|
|
31
|
+
return fieldCap ?? propCap ?? DEFAULT_MAX_UPLOAD_BYTES;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The DECODED byte size of a media field's string value. The value is either a
|
|
36
|
+
* raw base64 body or a full `data:<mime>;base64,<body>` URL; the cap applies to
|
|
37
|
+
* the decoded bytes, never the character length. Computed arithmetically from the
|
|
38
|
+
* base64 length and its `=` padding, so an over-cap value is caught without
|
|
39
|
+
* allocating the decoded buffer and without throwing on a malformed input.
|
|
40
|
+
*/
|
|
41
|
+
export function decodedByteSize(value: string): number {
|
|
42
|
+
// A data-url carries the base64 body after the first comma; a bare base64 value
|
|
43
|
+
// is the body itself.
|
|
44
|
+
const comma = value.indexOf(',');
|
|
45
|
+
const body = value.startsWith('data:') && comma >= 0 ? value.slice(comma + 1) : value;
|
|
46
|
+
// base64 decodes 4 chars → 3 bytes; drop whitespace and subtract the trailing
|
|
47
|
+
// `=` padding the encoder added.
|
|
48
|
+
const compact = body.replace(/\s/g, '');
|
|
49
|
+
if (compact === '') return 0;
|
|
50
|
+
const padding = compact.endsWith('==') ? 2 : compact.endsWith('=') ? 1 : 0;
|
|
51
|
+
return Math.floor((compact.length * 3) / 4) - padding;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Human-readable byte size for a cap/overage message (`5 B`, `1.5 KB`, `2.0 MB`). */
|
|
55
|
+
function formatBytes(bytes: number): string {
|
|
56
|
+
if (bytes < 1024) return `${String(bytes)} B`;
|
|
57
|
+
const kb = bytes / 1024;
|
|
58
|
+
if (kb < 1024) return `${kb.toFixed(1)} KB`;
|
|
59
|
+
return `${(kb / 1024).toFixed(1)} MB`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The loud, user-visible over-cap message, identical across the picker, the paste
|
|
64
|
+
* fallback, and validation. `subject` names the rejected input (e.g. a quoted
|
|
65
|
+
* filename, or `"The pasted value"`).
|
|
66
|
+
*/
|
|
67
|
+
export function overCapMessage(subject: string, actualBytes: number, maxBytes: number): string {
|
|
68
|
+
return `${subject} is ${formatBytes(actualBytes)}, over the ${formatBytes(maxBytes)} limit.`;
|
|
69
|
+
}
|