@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,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owned DS primitives, wearing the design-system classes from
|
|
3
|
+
* `components.css`. These cover the shared UI states every feature drives from
|
|
4
|
+
* TanStack Query: loading skeletons, a shared empty state, and a loud error
|
|
5
|
+
* surface. Plugins consume these — never raw Tailwind utilities.
|
|
6
|
+
*
|
|
7
|
+
* `Button` is ONE implementation for both an action and a link. Given an `href`
|
|
8
|
+
* it renders an anchor, and that href is checked against an http/https
|
|
9
|
+
* allow-list: a reference beginning `/`, `?`, `#`, `./` or `../` stays in-app, an
|
|
10
|
+
* absolute `http://`/`https://` URL opens in a new tab with
|
|
11
|
+
* `rel="noopener noreferrer external"`, and everything else — another scheme
|
|
12
|
+
* (`javascript:`, `data:`, …), a protocol-relative `//host`, a bare `page.html` —
|
|
13
|
+
* is NEUTRALIZED, rendered as plain text with no href, so a hostile URL can never
|
|
14
|
+
* become a live navigation target. This is an XSS pin. The neutralized text names
|
|
15
|
+
* BOTH admitted forms, because an in-app reference is not an http(s) URL either
|
|
16
|
+
* and saying only "not an http(s) URL" would describe `/tools` as blocked.
|
|
17
|
+
*
|
|
18
|
+
* The check reads the NORMALIZED reference and the anchor is given that same
|
|
19
|
+
* normalized string, because the raw input and the URL the browser resolves are
|
|
20
|
+
* not the same URL. See `normalizeHref`.
|
|
21
|
+
*/
|
|
22
|
+
import type {
|
|
23
|
+
AnchorHTMLAttributes,
|
|
24
|
+
ButtonHTMLAttributes,
|
|
25
|
+
CSSProperties,
|
|
26
|
+
ReactNode,
|
|
27
|
+
Ref,
|
|
28
|
+
} from 'react';
|
|
29
|
+
|
|
30
|
+
import { XCircleIcon } from './icons';
|
|
31
|
+
|
|
32
|
+
// -- Link safety --------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The reference with the characters the URL parser throws away before it parses:
|
|
36
|
+
* ASCII tab, LF and CR ANYWHERE in the string, plus leading and trailing C0
|
|
37
|
+
* controls and spaces.
|
|
38
|
+
*
|
|
39
|
+
* Reading the raw string instead means checking a different URL from the one the
|
|
40
|
+
* browser resolves. `/<TAB>/evil.com` is not a root-relative path — the parser
|
|
41
|
+
* deletes the tab, making it the protocol-relative `//evil.com`, which is
|
|
42
|
+
* cross-origin.
|
|
43
|
+
*/
|
|
44
|
+
function normalizeHref(href: string): string {
|
|
45
|
+
const inner = href.replaceAll(/[\t\n\r]/g, '');
|
|
46
|
+
// The leading/trailing strip walks code points rather than using a character
|
|
47
|
+
// class, so it covers every C0 control without a control-character regex.
|
|
48
|
+
let start = 0;
|
|
49
|
+
let end = inner.length;
|
|
50
|
+
while (start < end && inner.charCodeAt(start) <= 0x20) start++;
|
|
51
|
+
while (end > start && inner.charCodeAt(end - 1) <= 0x20) end--;
|
|
52
|
+
return inner.slice(start, end);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The absolute http(s) URL `href` denotes, or `undefined` when it denotes none.
|
|
57
|
+
*
|
|
58
|
+
* The `//` is required rather than inferred from a successful `new URL()`,
|
|
59
|
+
* because for a scheme that matches the document's own, the authority-less
|
|
60
|
+
* spelling is a PATH: an anchor with `href="https:/settings"` on an https page
|
|
61
|
+
* navigates to `/settings` on the CURRENT origin, while `new URL()` with no base
|
|
62
|
+
* reads the same string as the host `settings`. Only the `//` form means the same
|
|
63
|
+
* thing to both.
|
|
64
|
+
*
|
|
65
|
+
* Callers render the returned string rather than their input, so the URL that was
|
|
66
|
+
* judged is always the URL that is navigated.
|
|
67
|
+
*/
|
|
68
|
+
export function safeHttpUrl(href: string): string | undefined {
|
|
69
|
+
const normalized = normalizeHref(href);
|
|
70
|
+
if (!/^https?:\/\//i.test(normalized)) return undefined;
|
|
71
|
+
try {
|
|
72
|
+
return new URL(normalized).href;
|
|
73
|
+
} catch {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** True only for an absolute `http://`/`https://` URL. Everything else is unsafe. */
|
|
79
|
+
export function isSafeHttpUrl(url: string): boolean {
|
|
80
|
+
return safeHttpUrl(url) !== undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A same-document, root-relative or explicitly relative reference. The negative
|
|
85
|
+
* lookahead rejects a protocol-relative `//host` (and its `/\` spelling), which
|
|
86
|
+
* inherits the page scheme and so resolves to a cross-origin target rather than
|
|
87
|
+
* an in-app one. Tested against the NORMALIZED reference — see `normalizeHref`.
|
|
88
|
+
*
|
|
89
|
+
* The lookahead belongs to the `/` branch ALONE. Applied to `#` and `?` as well
|
|
90
|
+
* it rejected `#/agents` — a hash route, the most common client-routed reference
|
|
91
|
+
* form there is, and one of the five spellings this module's own docblock says
|
|
92
|
+
* "stays in-app" — sending it to the neutralized span reserved for
|
|
93
|
+
* `javascript:`. Neither `#//evil.example` nor `?//evil.example` can be
|
|
94
|
+
* cross-origin: both resolve against the current document, so the lookahead
|
|
95
|
+
* bought nothing on those two branches and cost a whole routing style.
|
|
96
|
+
*/
|
|
97
|
+
const RELATIVE_HREF = /^(?:\/(?![/\\])|[?#]|\.{1,2}\/)/;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The text a neutralized href carries in place of a navigation. It names both
|
|
101
|
+
* admitted forms: `/tools` and `#/agents` are in-app references rather than
|
|
102
|
+
* http(s) URLs and DO render as live anchors, so a reason citing only the
|
|
103
|
+
* http(s) rule would state a rule this component does not enforce.
|
|
104
|
+
*/
|
|
105
|
+
const BLOCKED_HREF_TITLE =
|
|
106
|
+
'This link was blocked because it is neither an in-app reference nor an http(s) URL.';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Takes a subtree out of the accessibility tree without taking it out of the
|
|
110
|
+
* layout: `display: contents` generates no box, so wrapped children stay direct
|
|
111
|
+
* flex items of the button surface around them.
|
|
112
|
+
*/
|
|
113
|
+
const hiddenFromReadersStyle: CSSProperties = { display: 'contents' };
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A blocked href, rendered as inert text: no anchor, no `href`, no handlers, so
|
|
117
|
+
* it can never become a live navigation target. Shared by `Button`'s link form
|
|
118
|
+
* and by `ExternalLinkButton`, which applies the stricter http(s)-only policy.
|
|
119
|
+
*
|
|
120
|
+
* Its surface is DELIBERATELY narrow. It takes the paint, the id and the name,
|
|
121
|
+
* and nothing else the caller wrote on the anchor: the props a live link needs
|
|
122
|
+
* are the props a dead one must not have, so they are refused here rather than
|
|
123
|
+
* spread onto the span.
|
|
124
|
+
*
|
|
125
|
+
* It stays ROLE-LESS on purpose — it is not a link and must not be announced as
|
|
126
|
+
* one. That is also why the caller's name arrives as visually-hidden TEXT rather
|
|
127
|
+
* than `aria-label`: ARIA prohibits both naming attributes on the `generic` role
|
|
128
|
+
* a bare `<span>` maps to, so a platform that honours the prohibition computes NO
|
|
129
|
+
* name from them and an icon-only blocked link announces as nothing at all. Text
|
|
130
|
+
* has no such restriction — a generic element contributes no accessible NAME, but
|
|
131
|
+
* its content is still read. `aria-disabled` stays because
|
|
132
|
+
* `.tai-btn[aria-disabled='true']` is what paints the disabled look; it is inert
|
|
133
|
+
* on a role-less element, and the missing `href` is what actually says this does
|
|
134
|
+
* not navigate.
|
|
135
|
+
*
|
|
136
|
+
* A caller-supplied `label` REPLACES the children for assistive tech, exactly as
|
|
137
|
+
* the `aria-label` it comes from would have done on the live anchor: the children
|
|
138
|
+
* go behind an `aria-hidden` wrapper that is `display: contents`, so it generates
|
|
139
|
+
* no box and the button's own flex layout is unchanged. Without that the children
|
|
140
|
+
* and the label are both read and a blocked link announces its name twice.
|
|
141
|
+
*/
|
|
142
|
+
export function NeutralizedLink({
|
|
143
|
+
className,
|
|
144
|
+
style,
|
|
145
|
+
children,
|
|
146
|
+
id,
|
|
147
|
+
label,
|
|
148
|
+
}: {
|
|
149
|
+
readonly className?: string;
|
|
150
|
+
readonly style?: CSSProperties;
|
|
151
|
+
readonly children?: ReactNode;
|
|
152
|
+
/** Kept so an external `aria-labelledby`/`aria-describedby` IDREF still lands. */
|
|
153
|
+
readonly id?: string;
|
|
154
|
+
/**
|
|
155
|
+
* The name the caller gave the link, rendered as hidden text. An icon-only
|
|
156
|
+
* link carries its whole meaning here, and when it is given the children are
|
|
157
|
+
* hidden from assistive tech so the name is read once, not twice.
|
|
158
|
+
*/
|
|
159
|
+
readonly label?: string;
|
|
160
|
+
}) {
|
|
161
|
+
return (
|
|
162
|
+
<span
|
|
163
|
+
data-neutralized="true"
|
|
164
|
+
aria-disabled="true"
|
|
165
|
+
title={BLOCKED_HREF_TITLE}
|
|
166
|
+
className={className}
|
|
167
|
+
style={style}
|
|
168
|
+
id={id}
|
|
169
|
+
>
|
|
170
|
+
{label === undefined ? (
|
|
171
|
+
children
|
|
172
|
+
) : (
|
|
173
|
+
<span aria-hidden="true" style={hiddenFromReadersStyle}>
|
|
174
|
+
{children}
|
|
175
|
+
</span>
|
|
176
|
+
)}
|
|
177
|
+
{/* `title` on a non-focusable span is not reliably announced, so the name
|
|
178
|
+
and the reason the link is dead are carried as real text. An icon-only
|
|
179
|
+
link would otherwise neutralize into a silent nothing. */}
|
|
180
|
+
<span className="tai-visually-hidden">
|
|
181
|
+
{label === undefined ? BLOCKED_HREF_TITLE : `${label}. ${BLOCKED_HREF_TITLE}`}
|
|
182
|
+
</span>
|
|
183
|
+
</span>
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface ResolvedHref {
|
|
188
|
+
readonly kind: 'internal' | 'external' | 'blocked';
|
|
189
|
+
/** The reference to put in the anchor: exactly the one that was judged. */
|
|
190
|
+
readonly href: string;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Sorts an href into the three link forms the button renders, and returns the
|
|
195
|
+
* normalized reference alongside. Rendering the caller's raw string instead
|
|
196
|
+
* would let the anchor navigate somewhere the check never saw.
|
|
197
|
+
*/
|
|
198
|
+
function resolveHref(href: string): ResolvedHref {
|
|
199
|
+
const external = safeHttpUrl(href);
|
|
200
|
+
if (external !== undefined) return { kind: 'external', href: external };
|
|
201
|
+
const normalized = normalizeHref(href);
|
|
202
|
+
if (RELATIVE_HREF.test(normalized)) return { kind: 'internal', href: normalized };
|
|
203
|
+
return { kind: 'blocked', href: '' };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// -- Shared surface props -----------------------------------------------------
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* What every primitive in this file accepts alongside its own props: the
|
|
210
|
+
* design-system class it wears is its own, and the caller's `className` is
|
|
211
|
+
* APPENDED to it rather than replacing it, so a surface can never lose its paint
|
|
212
|
+
* by being positioned.
|
|
213
|
+
*/
|
|
214
|
+
interface SurfaceProps {
|
|
215
|
+
readonly className?: string;
|
|
216
|
+
readonly style?: CSSProperties;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** The surface's own class plus the caller's, which sorts last so it can override. */
|
|
220
|
+
function surfaceClass(base: string, className: string | undefined): string {
|
|
221
|
+
return className === undefined ? base : `${base} ${className}`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// -- Button ------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
|
|
227
|
+
|
|
228
|
+
/** The variant a `Button` wears when the caller names none. */
|
|
229
|
+
export const DEFAULT_BUTTON_VARIANT: ButtonVariant = 'secondary';
|
|
230
|
+
|
|
231
|
+
const VARIANT_CLASS: Record<ButtonVariant, string> = {
|
|
232
|
+
primary: 'tai-btn tai-btn-primary',
|
|
233
|
+
secondary: 'tai-btn tai-btn-secondary',
|
|
234
|
+
ghost: 'tai-btn tai-btn-ghost',
|
|
235
|
+
danger: 'tai-btn tai-btn-danger',
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The one prop both forms share. Deliberately NOT on the public surface: it is a
|
|
240
|
+
* shared base, and declaration emit keeps it visible inside `primitives.d.ts`
|
|
241
|
+
* where `ButtonProps`/`LinkButtonProps` reference it, so consumers still see
|
|
242
|
+
* `variant` and can still `extends` either interface.
|
|
243
|
+
*/
|
|
244
|
+
interface ButtonVariantProps {
|
|
245
|
+
readonly variant?: ButtonVariant;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The ACTION form of `Button`: button attributes, no anchor ones.
|
|
250
|
+
*
|
|
251
|
+
* This stays an INTERFACE extending `ButtonHTMLAttributes<HTMLButtonElement>`
|
|
252
|
+
* because it is published plugin API: a plugin may write
|
|
253
|
+
* `interface MyButton extends ButtonProps {}` (a union cannot be extended —
|
|
254
|
+
* TS2312) and may hand a `ButtonProps` value to a slot typed as plain
|
|
255
|
+
* `ButtonHTMLAttributes<HTMLButtonElement>` (a union including anchor attributes
|
|
256
|
+
* is not assignable — TS2322). The link form is the ADDITIVE `LinkButtonProps`,
|
|
257
|
+
* and `Button` accepts either.
|
|
258
|
+
*
|
|
259
|
+
* It declares NO `href` member. `href?: undefined` here would make
|
|
260
|
+
* `interface MyButton extends ButtonProps { href: string }` a TS2430
|
|
261
|
+
* ("incorrectly extends"), which is a narrowing of a surface that is additive
|
|
262
|
+
* only. The discriminant `Button` narrows on lives on `ButtonActionProps`.
|
|
263
|
+
*/
|
|
264
|
+
export interface ButtonProps extends ButtonVariantProps, ButtonHTMLAttributes<HTMLButtonElement> {
|
|
265
|
+
/** A consumer ref for the `<button>`; the prop spread forwards it. */
|
|
266
|
+
readonly ref?: Ref<HTMLButtonElement>;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* The action form as `Button` ACCEPTS it: `ButtonProps` plus the absent-`href`
|
|
271
|
+
* discriminant, which is what lets the body narrow `props.href` without a cast.
|
|
272
|
+
*
|
|
273
|
+
* Like `ButtonVariantProps` this is deliberately off the public surface — a
|
|
274
|
+
* plugin extends `ButtonProps` — and declaration emit keeps it visible inside
|
|
275
|
+
* `primitives.d.ts`, where `Button`'s signature references it.
|
|
276
|
+
*/
|
|
277
|
+
interface ButtonActionProps extends ButtonProps {
|
|
278
|
+
readonly href?: undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The LINK form of `Button`: an `href`, so it carries anchor attributes and no
|
|
283
|
+
* button ones.
|
|
284
|
+
*
|
|
285
|
+
* `target` and `rel` are inherited from `AnchorHTMLAttributes` and are settable,
|
|
286
|
+
* but on the EXTERNAL branch they are not the caller's to choose: an absolute
|
|
287
|
+
* http(s) URL always renders `target="_blank"` with
|
|
288
|
+
* `rel="noopener noreferrer external"`, whatever the caller passed. On the
|
|
289
|
+
* in-app branch `target` is the caller's and `rel` is pinned only when they ask
|
|
290
|
+
* for `_blank`.
|
|
291
|
+
*
|
|
292
|
+
* It declares NO `ref`, and that is a REFUSAL rather than an omission: a blocked
|
|
293
|
+
* href renders a {@link NeutralizedLink} span and no anchor at all, so a
|
|
294
|
+
* `Ref<HTMLAnchorElement>` would be filled on two of the three renderings and
|
|
295
|
+
* left null on the third — a ref that silently does nothing for exactly the
|
|
296
|
+
* input the check exists to catch. A caller that needs the element wraps the
|
|
297
|
+
* button.
|
|
298
|
+
*/
|
|
299
|
+
export interface LinkButtonProps
|
|
300
|
+
extends ButtonVariantProps, AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
301
|
+
readonly href: string;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** The variant class plus the caller's, which sorts last so it can override. */
|
|
305
|
+
export function buttonClass(variant: ButtonVariant, className: string | undefined): string {
|
|
306
|
+
return surfaceClass(VARIANT_CLASS[variant], className);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function Button(props: ButtonActionProps | LinkButtonProps) {
|
|
310
|
+
if (props.href === undefined) {
|
|
311
|
+
const { variant = DEFAULT_BUTTON_VARIANT, className, href: _href, ...rest } = props;
|
|
312
|
+
return <button {...rest} className={buttonClass(variant, className)} />;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const { variant = DEFAULT_BUTTON_VARIANT, className, style, href, children, ...rest } = props;
|
|
316
|
+
const classes = buttonClass(variant, className);
|
|
317
|
+
const link = resolveHref(href);
|
|
318
|
+
|
|
319
|
+
if (link.kind === 'blocked') {
|
|
320
|
+
// Only the four props that survive the neutralization cross: the paint, the
|
|
321
|
+
// id an external IDREF may point at, and the caller's name (as text). Every
|
|
322
|
+
// other anchor prop is REFUSED rather than forwarded — `target`/`rel`
|
|
323
|
+
// describe a navigation that is not happening, and a handler spread onto the
|
|
324
|
+
// span would make the dead link live again, which is the whole defect.
|
|
325
|
+
return (
|
|
326
|
+
<NeutralizedLink className={classes} style={style} id={rest.id} label={rest['aria-label']}>
|
|
327
|
+
{children}
|
|
328
|
+
</NeutralizedLink>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
if (link.kind === 'internal') {
|
|
333
|
+
return (
|
|
334
|
+
<a
|
|
335
|
+
{...rest}
|
|
336
|
+
href={link.href}
|
|
337
|
+
// An in-app link is same-origin, but a caller may still ask for a new
|
|
338
|
+
// tab; `rel` is pinned after the spread so that ask cannot drop it.
|
|
339
|
+
{...(rest.target === '_blank' ? { rel: 'noopener noreferrer' } : {})}
|
|
340
|
+
className={classes}
|
|
341
|
+
style={style}
|
|
342
|
+
>
|
|
343
|
+
{children}
|
|
344
|
+
</a>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<a
|
|
350
|
+
{...rest}
|
|
351
|
+
// REVERSE TABNABBING. `target`/`rel` are settable on `LinkButtonProps`, so
|
|
352
|
+
// the hardening is carried by JSX property ORDER alone: the spread comes
|
|
353
|
+
// FIRST and these two are written after it, which is what makes them
|
|
354
|
+
// unoverridable. Below the spread, `rel="opener" target="_self"` from a
|
|
355
|
+
// caller would open a cross-origin `_blank` with a live `window.opener`
|
|
356
|
+
// onto the operator's authenticated tab.
|
|
357
|
+
href={link.href}
|
|
358
|
+
target="_blank"
|
|
359
|
+
rel="noopener noreferrer external"
|
|
360
|
+
className={classes}
|
|
361
|
+
style={style}
|
|
362
|
+
>
|
|
363
|
+
{children}
|
|
364
|
+
</a>
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// -- Card --------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
export interface CardProps extends SurfaceProps {
|
|
371
|
+
readonly children: ReactNode;
|
|
372
|
+
/** Opt in to the hover/focus lift. A card that is not itself an affordance stays flat. */
|
|
373
|
+
readonly interactive?: boolean;
|
|
374
|
+
/** A consumer ref for the card element itself. */
|
|
375
|
+
readonly ref?: Ref<HTMLDivElement>;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export function Card({ children, interactive = false, className, style, ref }: CardProps) {
|
|
379
|
+
const base = interactive ? 'tai-card tai-card-interactive' : 'tai-card';
|
|
380
|
+
return (
|
|
381
|
+
<div className={surfaceClass(base, className)} style={style} ref={ref}>
|
|
382
|
+
{children}
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// -- Skeleton (loading) ------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
export interface SkeletonProps extends SurfaceProps {
|
|
390
|
+
readonly height?: number | string;
|
|
391
|
+
readonly width?: number | string;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
export function Skeleton({ height = 16, width = '100%', className, style }: SkeletonProps) {
|
|
395
|
+
// The block's extent is per-instance; the sheen animation lives in the class.
|
|
396
|
+
return (
|
|
397
|
+
<div
|
|
398
|
+
aria-hidden="true"
|
|
399
|
+
className={surfaceClass('tai-skeleton', className)}
|
|
400
|
+
style={{ height, width, ...style }}
|
|
401
|
+
/>
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// -- EmptyState --------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
export interface EmptyStateProps extends SurfaceProps {
|
|
408
|
+
readonly title: string;
|
|
409
|
+
readonly description?: string;
|
|
410
|
+
/** The guidance line's on-screen action — the control the description names. */
|
|
411
|
+
readonly action?: ReactNode;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function EmptyState({ title, description, action, className, style }: EmptyStateProps) {
|
|
415
|
+
return (
|
|
416
|
+
<div role="status" className={surfaceClass('tai-empty-state', className)} style={style}>
|
|
417
|
+
<p className="tai-empty-state-title">{title}</p>
|
|
418
|
+
{description === undefined ? null : (
|
|
419
|
+
<p style={{ margin: 'var(--tai-space-2) 0 0' }}>{description}</p>
|
|
420
|
+
)}
|
|
421
|
+
{action === undefined ? null : (
|
|
422
|
+
<div style={{ marginTop: 'var(--tai-space-4)' }}>{action}</div>
|
|
423
|
+
)}
|
|
424
|
+
</div>
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// -- ErrorState (loud, visible) ----------------------------------------------
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* The headline every `ErrorState` wears.
|
|
432
|
+
*
|
|
433
|
+
* It is fixed, and the whole surface says one thing with it: `role="alert"`
|
|
434
|
+
* interrupts, the crossed circle is the ERROR mark, and `.tai-error-state` is the
|
|
435
|
+
* error ground. A DELIBERATE refusal — a permission denial, a server's dry-run
|
|
436
|
+
* "no" — is an answer rather than a malfunction, and swapping this headline alone
|
|
437
|
+
* would leave the mark, the ground and the live-region politeness all still
|
|
438
|
+
* announcing a system failure. Such a surface takes the design system's warn
|
|
439
|
+
* ground instead — `.tai-warn-state` for a warning block (`advisories.tsx`,
|
|
440
|
+
* `fleet-report.tsx`, `RunPanel.tsx`), or `role="status"` with a warn `Badge` for
|
|
441
|
+
* a verdict that only lands after the reader acts (`verdict.tsx`). The live-region
|
|
442
|
+
* politeness — `role="alert"` for a warning that interrupts, `role="status"` for
|
|
443
|
+
* one the reader came to find — is the surface's to choose, not this headline's.
|
|
444
|
+
*/
|
|
445
|
+
const ERROR_TITLE = 'Something went wrong';
|
|
446
|
+
|
|
447
|
+
export interface ErrorStateProps extends SurfaceProps {
|
|
448
|
+
readonly message: string;
|
|
449
|
+
readonly onRetry?: () => void;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function ErrorState({ message, onRetry, className, style }: ErrorStateProps) {
|
|
453
|
+
return (
|
|
454
|
+
<div role="alert" className={surfaceClass('tai-error-state', className)} style={style}>
|
|
455
|
+
{/* The icon carries the state alongside the color, never the color alone,
|
|
456
|
+
and it is the ERROR mark: the crossed circle. The warning triangle is
|
|
457
|
+
the warn surfaces' mark and would state a second severity here. */}
|
|
458
|
+
<strong className="tai-error-state-title">
|
|
459
|
+
<XCircleIcon />
|
|
460
|
+
{ERROR_TITLE}
|
|
461
|
+
</strong>
|
|
462
|
+
<p style={{ margin: 'var(--tai-space-2) 0 0', whiteSpace: 'pre-wrap' }}>{message}</p>
|
|
463
|
+
{onRetry === undefined ? null : (
|
|
464
|
+
<div style={{ marginTop: 'var(--tai-space-3)' }}>
|
|
465
|
+
<Button type="button" onClick={onRetry}>
|
|
466
|
+
Retry
|
|
467
|
+
</Button>
|
|
468
|
+
</div>
|
|
469
|
+
)}
|
|
470
|
+
</div>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// -- Spinner -----------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
export interface SpinnerProps extends SurfaceProps {
|
|
477
|
+
readonly label?: string;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function Spinner({ label = 'Loading', className, style }: SpinnerProps) {
|
|
481
|
+
return (
|
|
482
|
+
<span
|
|
483
|
+
role="status"
|
|
484
|
+
aria-label={label}
|
|
485
|
+
className={surfaceClass('tai-spinner', className)}
|
|
486
|
+
style={style}
|
|
487
|
+
/>
|
|
488
|
+
);
|
|
489
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `ProgressBar` — a design-system, accessible progress indicator for a running
|
|
3
|
+
* tool that reports progress (`ctx.report_progress`). Determinate when a
|
|
4
|
+
* positive `total` is known (fills to `value/total`); otherwise indeterminate.
|
|
5
|
+
*
|
|
6
|
+
* The track and fill are classes; only the determinate fill's WIDTH stays inline,
|
|
7
|
+
* because it is the one genuinely per-instance value here. The indeterminate
|
|
8
|
+
* sweep is `tai-progress-fill-indeterminate`.
|
|
9
|
+
*
|
|
10
|
+
* Reusable by any feature's run surface — it takes only the raw
|
|
11
|
+
* progress/total/message a `report_progress` notification carries and renders
|
|
12
|
+
* a native `role="progressbar"` element with the ARIA value fields set.
|
|
13
|
+
*/
|
|
14
|
+
import type { CSSProperties, ReactNode } from 'react';
|
|
15
|
+
|
|
16
|
+
export interface ProgressBarProps {
|
|
17
|
+
/** Current progress. Clamped to `[0, total]` when `total` is known. */
|
|
18
|
+
readonly value?: number;
|
|
19
|
+
/** Upper bound. When absent or non-positive the bar is INDETERMINATE. */
|
|
20
|
+
readonly total?: number;
|
|
21
|
+
/** Optional human-readable status shown alongside the bar. */
|
|
22
|
+
readonly message?: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clampFraction(value: number, total: number): number {
|
|
26
|
+
if (!(total > 0)) return 0;
|
|
27
|
+
const fraction = value / total;
|
|
28
|
+
// Written as `>= 0` rather than `< 0` so that NaN — which compares false to
|
|
29
|
+
// everything, and would otherwise reach the width and the label as `NaN%` —
|
|
30
|
+
// falls through to the floor.
|
|
31
|
+
if (!(fraction >= 0)) return 0;
|
|
32
|
+
if (fraction > 1) return 1;
|
|
33
|
+
return fraction;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The value to ANNOUNCE, clamped into `[0, total]`. Derived from `value`
|
|
38
|
+
* directly rather than by multiplying the fraction back up: `value / total *
|
|
39
|
+
* total` does not round-trip in binary floating point, so `value={7}
|
|
40
|
+
* total={25}` announced `7.000000000000001`, and a non-finite `total` announced
|
|
41
|
+
* `NaN`. The same `>= 0` shape as `clampFraction` keeps NaN at the floor.
|
|
42
|
+
*/
|
|
43
|
+
function clampValue(value: number, total: number): number {
|
|
44
|
+
if (!(value >= 0)) return 0;
|
|
45
|
+
if (!(value <= total)) return total;
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The status sits at one end of the row and the percentage at the other. */
|
|
50
|
+
const labelRowStyle: CSSProperties = { justifyContent: 'space-between' };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Render a progress bar. With a positive `total`, `value/total` fills the track
|
|
54
|
+
* and the percentage is announced via `aria-valuenow`; without one, the bar is
|
|
55
|
+
* indeterminate (no `aria-valuenow`, per ARIA).
|
|
56
|
+
*/
|
|
57
|
+
export function ProgressBar({ value = 0, total, message }: ProgressBarProps): ReactNode {
|
|
58
|
+
// A determinate bar has to announce a real `aria-valuemax`. `Number.isFinite`
|
|
59
|
+
// rather than `> 0` alone: `total={Infinity}` passes the positivity test but
|
|
60
|
+
// announces `aria-valuemax="Infinity"`, which is not a number any assistive
|
|
61
|
+
// technology can place `aria-valuenow` against, and draws a 0 % fill while
|
|
62
|
+
// announcing the raw value. An unbounded total is exactly the indeterminate
|
|
63
|
+
// case, so it renders as one.
|
|
64
|
+
const determinate = typeof total === 'number' && Number.isFinite(total) && total > 0;
|
|
65
|
+
const fraction = determinate ? clampFraction(value, total) : undefined;
|
|
66
|
+
const percent = fraction === undefined ? undefined : Math.round(fraction * 100);
|
|
67
|
+
|
|
68
|
+
return (
|
|
69
|
+
<div className="tai-stack tai-stack-2">
|
|
70
|
+
{/* The status line always renders. An indeterminate bar under reduced
|
|
71
|
+
motion is a full, still track — visually identical to a finished one —
|
|
72
|
+
so the only thing separating "in flight" from "done" for a sighted
|
|
73
|
+
reader is this line saying so. */}
|
|
74
|
+
<div className="tai-row tai-field-hint" style={labelRowStyle}>
|
|
75
|
+
<span>{message ?? 'Working…'}</span>
|
|
76
|
+
{percent !== undefined && <span>{percent}%</span>}
|
|
77
|
+
</div>
|
|
78
|
+
<div
|
|
79
|
+
role="progressbar"
|
|
80
|
+
className="tai-progress-track"
|
|
81
|
+
aria-valuemin={0}
|
|
82
|
+
aria-valuemax={determinate ? total : undefined}
|
|
83
|
+
// The clamped value, not the raw prop: a bar drawn full while it
|
|
84
|
+
// announces `aria-valuenow="99"` against `aria-valuemax="10"` tells a
|
|
85
|
+
// screen-reader user something the screen does not.
|
|
86
|
+
aria-valuenow={determinate && fraction !== undefined ? clampValue(value, total) : undefined}
|
|
87
|
+
aria-label={message ?? 'Progress'}
|
|
88
|
+
>
|
|
89
|
+
{determinate ? (
|
|
90
|
+
<div
|
|
91
|
+
className="tai-progress-fill"
|
|
92
|
+
style={{ width: `${String((fraction ?? 0) * 100)}%` }}
|
|
93
|
+
/>
|
|
94
|
+
) : (
|
|
95
|
+
<div className="tai-progress-fill tai-progress-fill-indeterminate" />
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
}
|