@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,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `JsonDiff` — a GENERIC, kind-agnostic structural diff of two JSON values,
|
|
3
|
+
* hand-rolled (no diff library). It recursively walks OBJECT keys and emits one row
|
|
4
|
+
* per leaf difference: a key present only on one side is `added`/`removed`, a key
|
|
5
|
+
* whose value differs is `changed`.
|
|
6
|
+
*
|
|
7
|
+
* ARRAYS ARE LEAVES: an unequal array (by deep compare) emits exactly ONE `changed`
|
|
8
|
+
* row at the array's path carrying both whole array values — there are no per-index
|
|
9
|
+
* rows and no LCS alignment. A container object whose descendants differ emits rows
|
|
10
|
+
* only for the differing leaves, never for the container itself.
|
|
11
|
+
*
|
|
12
|
+
* Rendered as a table: the dotted `path` (e.g. `fixed_kwargs.city`) as code, the
|
|
13
|
+
* kind as a Badge (removed → danger, added → success, changed → neutral), and the
|
|
14
|
+
* before/after values as a `JsonTree` for objects/arrays or escaped text for
|
|
15
|
+
* scalars. Every value renders as React TEXT, so payload markup is escaped and this
|
|
16
|
+
* is never an HTML sink.
|
|
17
|
+
*
|
|
18
|
+
* Each row also carries its `tai-diff-*` tint, but the tint only REINFORCES the
|
|
19
|
+
* kind — the Badge spells the kind out in words, so a row is never identified by
|
|
20
|
+
* color alone.
|
|
21
|
+
*/
|
|
22
|
+
import { type ReactNode } from 'react';
|
|
23
|
+
|
|
24
|
+
import { Badge } from './badge';
|
|
25
|
+
import { JsonTree } from './json-tree';
|
|
26
|
+
import { Table, TBody, TD, TH, THead, TR } from './table';
|
|
27
|
+
|
|
28
|
+
/** One leaf difference between the two JSON values. */
|
|
29
|
+
export interface JsonDiffRow {
|
|
30
|
+
/** The dotted path to the differing key, e.g. `fixed_kwargs.city` (`''` = root). */
|
|
31
|
+
readonly path: string;
|
|
32
|
+
readonly kind: 'added' | 'removed' | 'changed';
|
|
33
|
+
/** The value on the LEFT side — set for `removed` and `changed` rows. */
|
|
34
|
+
readonly before?: unknown;
|
|
35
|
+
/** The value on the RIGHT side — set for `added` and `changed` rows. */
|
|
36
|
+
readonly after?: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
40
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Structural deep-equality over JSON values (arrays compared position-wise). */
|
|
44
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
45
|
+
if (a === b) return true;
|
|
46
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
47
|
+
return a.length === b.length && a.every((item, index) => deepEqual(item, b[index]));
|
|
48
|
+
}
|
|
49
|
+
if (isRecord(a) && isRecord(b)) {
|
|
50
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
51
|
+
for (const key of keys) {
|
|
52
|
+
if (!deepEqual(a[key], b[key])) return false;
|
|
53
|
+
}
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function childPath(path: string, key: string): string {
|
|
60
|
+
return path === '' ? key : `${path}.${key}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Recursively collect the leaf differences. Only when BOTH sides are plain objects
|
|
65
|
+
* does the walk descend by key; anything else (arrays, scalars, a type mismatch) is
|
|
66
|
+
* compared as a single leaf.
|
|
67
|
+
*/
|
|
68
|
+
function collect(path: string, before: unknown, after: unknown, rows: JsonDiffRow[]): void {
|
|
69
|
+
if (isRecord(before) && isRecord(after)) {
|
|
70
|
+
const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort((a, b) =>
|
|
71
|
+
a.localeCompare(b),
|
|
72
|
+
);
|
|
73
|
+
for (const key of keys) {
|
|
74
|
+
const next = childPath(path, key);
|
|
75
|
+
const inBefore = key in before;
|
|
76
|
+
const inAfter = key in after;
|
|
77
|
+
if (inBefore && !inAfter) {
|
|
78
|
+
rows.push({ path: next, kind: 'removed', before: before[key] });
|
|
79
|
+
} else if (!inBefore && inAfter) {
|
|
80
|
+
rows.push({ path: next, kind: 'added', after: after[key] });
|
|
81
|
+
} else {
|
|
82
|
+
collect(next, before[key], after[key], rows);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
if (!deepEqual(before, after)) {
|
|
88
|
+
rows.push({ path, kind: 'changed', before, after });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The pure structural diff — every leaf difference between `before` and `after`. */
|
|
93
|
+
export function diffJson(before: unknown, after: unknown): JsonDiffRow[] {
|
|
94
|
+
const rows: JsonDiffRow[] = [];
|
|
95
|
+
collect('', before, after, rows);
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const KIND_VARIANT = {
|
|
100
|
+
added: 'success',
|
|
101
|
+
removed: 'danger',
|
|
102
|
+
changed: 'neutral',
|
|
103
|
+
} as const;
|
|
104
|
+
|
|
105
|
+
/** The row tint for each kind — a reinforcement of the Badge, never the sole cue. */
|
|
106
|
+
const KIND_ROW_CLASS = {
|
|
107
|
+
added: 'tai-diff-added',
|
|
108
|
+
removed: 'tai-diff-removed',
|
|
109
|
+
changed: 'tai-diff-changed',
|
|
110
|
+
} as const;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Render one side's value: a `JsonTree` for objects/arrays, escaped text for
|
|
114
|
+
* scalars. The tree is a scroll region once it overflows, so it is named from
|
|
115
|
+
* the row's path and its side — a table of rows all announcing "JSON" tells a
|
|
116
|
+
* reader which pane they are in for none of them.
|
|
117
|
+
*/
|
|
118
|
+
function DiffValue({
|
|
119
|
+
value,
|
|
120
|
+
label,
|
|
121
|
+
}: {
|
|
122
|
+
readonly value: unknown;
|
|
123
|
+
readonly label: string;
|
|
124
|
+
}): ReactNode {
|
|
125
|
+
if (Array.isArray(value) || isRecord(value)) {
|
|
126
|
+
return <JsonTree data={value} defaultExpanded={false} label={label} />;
|
|
127
|
+
}
|
|
128
|
+
return <code className="tai-code">{JSON.stringify(value)}</code>;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The row's path as it reads on screen; the root diff has no path of its own. */
|
|
132
|
+
function pathLabel(path: string): string {
|
|
133
|
+
return path === '' ? '(root)' : path;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const emptyCell = <span className="tai-muted">—</span>;
|
|
137
|
+
|
|
138
|
+
export interface JsonDiffProps {
|
|
139
|
+
readonly before: unknown;
|
|
140
|
+
readonly after: unknown;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function JsonDiff({ before, after }: JsonDiffProps): ReactNode {
|
|
144
|
+
const rows = diffJson(before, after);
|
|
145
|
+
|
|
146
|
+
if (rows.length === 0) {
|
|
147
|
+
return <p className="tai-muted">No differences between the two values.</p>;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return (
|
|
151
|
+
<Table>
|
|
152
|
+
<THead>
|
|
153
|
+
<TR>
|
|
154
|
+
<TH>Path</TH>
|
|
155
|
+
<TH>Change</TH>
|
|
156
|
+
<TH>Before</TH>
|
|
157
|
+
<TH>After</TH>
|
|
158
|
+
</TR>
|
|
159
|
+
</THead>
|
|
160
|
+
<TBody>
|
|
161
|
+
{rows.map((row, index) => (
|
|
162
|
+
<TR
|
|
163
|
+
key={`${row.kind}-${row.path}-${String(index)}`}
|
|
164
|
+
className={KIND_ROW_CLASS[row.kind]}
|
|
165
|
+
data-testid={`diff-row-${row.path}`}
|
|
166
|
+
>
|
|
167
|
+
<TD className="tai-table-id" style={{ wordBreak: 'break-all' }}>
|
|
168
|
+
{pathLabel(row.path)}
|
|
169
|
+
</TD>
|
|
170
|
+
<TD>
|
|
171
|
+
<Badge variant={KIND_VARIANT[row.kind]}>{row.kind}</Badge>
|
|
172
|
+
</TD>
|
|
173
|
+
<TD>
|
|
174
|
+
{row.kind === 'added' ? (
|
|
175
|
+
emptyCell
|
|
176
|
+
) : (
|
|
177
|
+
<DiffValue value={row.before} label={`${pathLabel(row.path)} before`} />
|
|
178
|
+
)}
|
|
179
|
+
</TD>
|
|
180
|
+
<TD>
|
|
181
|
+
{row.kind === 'removed' ? (
|
|
182
|
+
emptyCell
|
|
183
|
+
) : (
|
|
184
|
+
<DiffValue value={row.after} label={`${pathLabel(row.path)} after`} />
|
|
185
|
+
)}
|
|
186
|
+
</TD>
|
|
187
|
+
</TR>
|
|
188
|
+
))}
|
|
189
|
+
</TBody>
|
|
190
|
+
</Table>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `JsonTree` — a collapsible viewer for arbitrary JSON on the design system's
|
|
3
|
+
* terminal ground (`tai-code-block`, which also supplies the mono face, the code
|
|
4
|
+
* size and the line rhythm every row inherits). Objects and arrays are native
|
|
5
|
+
* `<details>`/`<summary>` disclosures (keyboard-accessible, no custom ARIA
|
|
6
|
+
* needed); primitives render inline, tinted by the `tai-syntax-*` class for their
|
|
7
|
+
* type so a value's kind is readable at a glance. All values render as React TEXT
|
|
8
|
+
* children, so a payload containing markup (e.g. `<script>`) is escaped by React
|
|
9
|
+
* and never interpreted — this component is never an HTML sink.
|
|
10
|
+
*
|
|
11
|
+
* The pane IS the scrolling box, so it carries the region attributes itself
|
|
12
|
+
* rather than sitting inside a `ScrollRegion` wrapper that would add a second
|
|
13
|
+
* scroller: a deeply indented value makes it a named keyboard target, a shallow
|
|
14
|
+
* one leaves it an ordinary block.
|
|
15
|
+
*/
|
|
16
|
+
import type { CSSProperties, ReactElement } from 'react';
|
|
17
|
+
|
|
18
|
+
import { useOverflowRegion } from './scroll-region';
|
|
19
|
+
|
|
20
|
+
export interface JsonTreeProps {
|
|
21
|
+
readonly data: unknown;
|
|
22
|
+
readonly defaultExpanded?: boolean;
|
|
23
|
+
/** The region's accessible name, applied only while the pane actually scrolls. */
|
|
24
|
+
readonly label?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The region's name when the caller supplies none. */
|
|
28
|
+
const DEFAULT_LABEL = 'JSON';
|
|
29
|
+
|
|
30
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function primitiveText(value: unknown): string {
|
|
35
|
+
if (value === null) return 'null';
|
|
36
|
+
switch (typeof value) {
|
|
37
|
+
case 'undefined':
|
|
38
|
+
return 'undefined';
|
|
39
|
+
case 'string':
|
|
40
|
+
return `"${value}"`;
|
|
41
|
+
case 'number':
|
|
42
|
+
case 'bigint':
|
|
43
|
+
case 'boolean':
|
|
44
|
+
return String(value);
|
|
45
|
+
case 'symbol':
|
|
46
|
+
return value.toString();
|
|
47
|
+
case 'function':
|
|
48
|
+
return '[Function]';
|
|
49
|
+
default:
|
|
50
|
+
return '[object]';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* One nesting level's indent: the child block insets and draws its own guide
|
|
56
|
+
* rail. The depth is expressed by the DOM nesting, so each level repeats this
|
|
57
|
+
* single step rather than computing an absolute offset.
|
|
58
|
+
*/
|
|
59
|
+
const childrenStyle: CSSProperties = {
|
|
60
|
+
paddingLeft: 'var(--tai-space-4)',
|
|
61
|
+
marginLeft: 'var(--tai-space-1)',
|
|
62
|
+
borderLeft: '1px solid var(--tai-color-decor)',
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** A `<summary>` is a click target; browsers do not style it as one by default. */
|
|
66
|
+
const summaryStyle: CSSProperties = { cursor: 'pointer' };
|
|
67
|
+
|
|
68
|
+
interface NodeProps {
|
|
69
|
+
readonly name?: string;
|
|
70
|
+
readonly value: unknown;
|
|
71
|
+
readonly defaultExpanded: boolean;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The syntax class for a primitive's type; anything outside JSON reads as muted. */
|
|
75
|
+
function primitiveClass(value: unknown): string {
|
|
76
|
+
if (typeof value === 'string') return 'tai-syntax-string';
|
|
77
|
+
if (typeof value === 'number' || typeof value === 'bigint') return 'tai-syntax-number';
|
|
78
|
+
if (typeof value === 'boolean') return 'tai-syntax-bool';
|
|
79
|
+
if (value === null || value === undefined) return 'tai-syntax-null';
|
|
80
|
+
return 'tai-muted';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function JsonNode({ name, value, defaultExpanded }: NodeProps): ReactElement {
|
|
84
|
+
if (Array.isArray(value) || isRecord(value)) {
|
|
85
|
+
const entries: [string, unknown][] = Array.isArray(value)
|
|
86
|
+
? value.map((item, index) => [String(index), item])
|
|
87
|
+
: Object.entries(value);
|
|
88
|
+
const summary = Array.isArray(value)
|
|
89
|
+
? `Array(${String(value.length)})`
|
|
90
|
+
: `Object(${String(entries.length)})`;
|
|
91
|
+
return (
|
|
92
|
+
<details open={defaultExpanded}>
|
|
93
|
+
<summary style={summaryStyle}>
|
|
94
|
+
{name !== undefined ? <span className="tai-syntax-key">{name}: </span> : null}
|
|
95
|
+
<span className="tai-muted">{summary}</span>
|
|
96
|
+
</summary>
|
|
97
|
+
<div style={childrenStyle}>
|
|
98
|
+
{entries.map(([childName, childValue]) => (
|
|
99
|
+
<JsonNode
|
|
100
|
+
key={childName}
|
|
101
|
+
name={childName}
|
|
102
|
+
value={childValue}
|
|
103
|
+
defaultExpanded={defaultExpanded}
|
|
104
|
+
/>
|
|
105
|
+
))}
|
|
106
|
+
</div>
|
|
107
|
+
</details>
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<div>
|
|
113
|
+
{name !== undefined ? <span className="tai-syntax-key">{name}: </span> : null}
|
|
114
|
+
<span className={primitiveClass(value)}>{primitiveText(value)}</span>
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function JsonTree({ data, defaultExpanded = true, label }: JsonTreeProps) {
|
|
120
|
+
// No consumer ref: the measurement's own callback ref is the only thing that
|
|
121
|
+
// needs the pane, and this component publishes no `ref` prop.
|
|
122
|
+
const region = useOverflowRegion(undefined, label ?? DEFAULT_LABEL);
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<div className="tai-code-block" {...region}>
|
|
126
|
+
<JsonNode value={data} defaultExpanded={defaultExpanded} />
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus return for a modal that renders no Radix `Trigger`.
|
|
3
|
+
*
|
|
4
|
+
* Radix composes its own close handler AFTER the consumer's — `composeEventHandlers`
|
|
5
|
+
* runs the consumer's `onCloseAutoFocus` first and skips Radix's only when that
|
|
6
|
+
* handler called `preventDefault`. Radix's handler is
|
|
7
|
+
* `event.preventDefault(); triggerRef.current?.focus()`: it returns focus to the
|
|
8
|
+
* Trigger and cancels the focus scope's restore in the same breath. A modal
|
|
9
|
+
* driven purely by `open`/`onOpenChange` — every confirm mounted for the
|
|
10
|
+
* duration of one action — has no Trigger for that ref to point at, so if that
|
|
11
|
+
* handler is allowed to run the cancel lands, the focus does not, and the reader
|
|
12
|
+
* is left on `<body>` with their place in the page gone (WCAG 2.4.3).
|
|
13
|
+
*
|
|
14
|
+
* So with no Trigger this stands in for one and never yields the decision back:
|
|
15
|
+
* it prevents default unconditionally — Radix's restore is a guaranteed no-op
|
|
16
|
+
* here, and a `<body>` if allowed to run — and then lands the focus itself, on
|
|
17
|
+
* the first of these that will take it:
|
|
18
|
+
*
|
|
19
|
+
* 1. the element focus left on the way in, when it can still hold focus;
|
|
20
|
+
* 2. the nearest of that element's ancestors still in the document, borrowing
|
|
21
|
+
* it a `tabindex="-1"` if it has none.
|
|
22
|
+
*
|
|
23
|
+
* The second case is the common one, not the exotic one: the action a confirm
|
|
24
|
+
* confirms is usually what removes, disables or hides the control that opened
|
|
25
|
+
* it. The list or table the vanished row sat in is still on screen and is where
|
|
26
|
+
* the reader was, so that is where they are put back.
|
|
27
|
+
*
|
|
28
|
+
* Those ancestors are recorded on the way IN because they cannot be recovered on
|
|
29
|
+
* the way out: detaching a node clears its own `parentElement`, so walking up
|
|
30
|
+
* from a removed row's button dead-ends inside the removed subtree instead of
|
|
31
|
+
* reaching the list that is still there. While the opener is still attached its
|
|
32
|
+
* live chain is used instead, so an opener that moved is followed rather than
|
|
33
|
+
* remembered wrongly.
|
|
34
|
+
*
|
|
35
|
+
* A borrowed `tabindex` is handed back on the element's `blur`, so the DOM keeps
|
|
36
|
+
* the attribute only while that element actually holds focus — and an element
|
|
37
|
+
* that is itself removed first takes the listener with it. Removing it any
|
|
38
|
+
* earlier would strip the attribute out from under the focus it exists to hold.
|
|
39
|
+
*
|
|
40
|
+
* When a Trigger IS rendered both handlers stand down and prevent nothing, so
|
|
41
|
+
* Radix's own restore runs untouched — there is never a second mechanism
|
|
42
|
+
* competing with the primitive.
|
|
43
|
+
*/
|
|
44
|
+
import { useRef } from 'react';
|
|
45
|
+
|
|
46
|
+
export interface ModalFocusReturnHandlers {
|
|
47
|
+
/** Runs before Radix moves focus into the panel, while the opener still holds it. */
|
|
48
|
+
readonly onOpenAutoFocus: (event: Event) => void;
|
|
49
|
+
/** Runs as the panel unmounts, before the focus scope would restore. */
|
|
50
|
+
readonly onCloseAutoFocus: (event: Event) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* `element`'s ancestors, nearest first, stopping short of `<body>` — landing on
|
|
55
|
+
* `<body>` is the failure this module exists to prevent, and it is already where
|
|
56
|
+
* focus falls when nothing else will take it.
|
|
57
|
+
*/
|
|
58
|
+
function ancestorChain(element: HTMLElement): HTMLElement[] {
|
|
59
|
+
const chain: HTMLElement[] = [];
|
|
60
|
+
const { body } = element.ownerDocument;
|
|
61
|
+
for (
|
|
62
|
+
let node = element.parentElement;
|
|
63
|
+
node !== null && node !== body;
|
|
64
|
+
node = node.parentElement
|
|
65
|
+
) {
|
|
66
|
+
chain.push(node);
|
|
67
|
+
}
|
|
68
|
+
return chain;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Focuses a container that was never meant to be focusable, lending it a
|
|
73
|
+
* `tabindex="-1"` if it has none and taking that back when it loses focus.
|
|
74
|
+
*
|
|
75
|
+
* @returns whether the focus actually landed.
|
|
76
|
+
*/
|
|
77
|
+
function focusAsFallback(element: HTMLElement): boolean {
|
|
78
|
+
const borrowed = !element.hasAttribute('tabindex');
|
|
79
|
+
if (borrowed) element.setAttribute('tabindex', '-1');
|
|
80
|
+
element.focus();
|
|
81
|
+
if (element.ownerDocument.activeElement !== element) {
|
|
82
|
+
if (borrowed) element.removeAttribute('tabindex');
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
if (borrowed) {
|
|
86
|
+
element.addEventListener(
|
|
87
|
+
'blur',
|
|
88
|
+
() => {
|
|
89
|
+
element.removeAttribute('tabindex');
|
|
90
|
+
},
|
|
91
|
+
{ once: true },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The `onOpenAutoFocus` / `onCloseAutoFocus` pair to spread onto a Radix
|
|
99
|
+
* `Dialog.Content`.
|
|
100
|
+
*
|
|
101
|
+
* @param hasTrigger - whether a Radix `Trigger` is rendered. When it is, both
|
|
102
|
+
* handlers stand down and Radix's own focus return applies.
|
|
103
|
+
*/
|
|
104
|
+
export function useModalFocusReturn(hasTrigger: boolean): ModalFocusReturnHandlers {
|
|
105
|
+
const openerRef = useRef<HTMLElement | null>(null);
|
|
106
|
+
const openedUnderRef = useRef<HTMLElement[]>([]);
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
onOpenAutoFocus: (): void => {
|
|
110
|
+
// Recording is only ever read by the close handler, which stands down with
|
|
111
|
+
// a Trigger present. Standing down here too keeps the pair symmetric and
|
|
112
|
+
// stops the hook holding the opener and its whole ancestor chain alive for
|
|
113
|
+
// a modal that will never consult them.
|
|
114
|
+
if (hasTrigger) return;
|
|
115
|
+
// The focus scope dispatches this BEFORE it focuses the panel, so the
|
|
116
|
+
// active element is still whatever the reader activated to open the modal,
|
|
117
|
+
// and its ancestors are still attached to record.
|
|
118
|
+
const active = document.activeElement;
|
|
119
|
+
const opener = active instanceof HTMLElement ? active : null;
|
|
120
|
+
openerRef.current = opener;
|
|
121
|
+
openedUnderRef.current = opener === null ? [] : ancestorChain(opener);
|
|
122
|
+
},
|
|
123
|
+
onCloseAutoFocus: (event: Event): void => {
|
|
124
|
+
if (hasTrigger) return;
|
|
125
|
+
const opener = openerRef.current;
|
|
126
|
+
const openedUnder = openedUnderRef.current;
|
|
127
|
+
openerRef.current = null;
|
|
128
|
+
openedUnderRef.current = [];
|
|
129
|
+
|
|
130
|
+
// Radix's handler runs next unless this one prevents default. Its
|
|
131
|
+
// `triggerRef` is null by definition here, and it cancels the scope's
|
|
132
|
+
// restore before finding that out, so letting it run is a guaranteed
|
|
133
|
+
// `<body>`. Take the decision away from it in every case, including the
|
|
134
|
+
// one where there is nothing to return focus to.
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
if (opener === null) return;
|
|
137
|
+
|
|
138
|
+
opener.focus();
|
|
139
|
+
if (opener.ownerDocument.activeElement === opener) return;
|
|
140
|
+
|
|
141
|
+
// The opener would not take focus — it was removed, disabled or hidden by
|
|
142
|
+
// the action this modal confirmed. Fall back to the nearest ancestor that
|
|
143
|
+
// is still in the document, so the reader keeps their place in the page.
|
|
144
|
+
const chain = opener.isConnected ? ancestorChain(opener) : openedUnder;
|
|
145
|
+
for (const ancestor of chain) {
|
|
146
|
+
if (ancestor.isConnected && focusAsFallback(ancestor)) return;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PageHeader`, `Page`, `Stack` — the screen scaffolding every feature was
|
|
3
|
+
* hand-rolling as a flex column plus an ad-hoc heading block.
|
|
4
|
+
*
|
|
5
|
+
* `PageHeader` owns a DOM contract `page-header.test.tsx` asserts: exactly one
|
|
6
|
+
* `<h1>` whose accessible name is the title VERBATIM. The nav-section eyebrow a
|
|
7
|
+
* screen shows above its title is therefore a SIBLING of the `<h1>`, never a
|
|
8
|
+
* child — nesting it would fold it into the computed name and break every
|
|
9
|
+
* heading assertion. The `<h1>` is also permanently focusable
|
|
10
|
+
* (`tabIndex={-1}`), which is what makes it addressable as a focus target
|
|
11
|
+
* through {@link PageHeaderProps.titleRef} or `id`.
|
|
12
|
+
*/
|
|
13
|
+
import type { CSSProperties, ReactElement, ReactNode, Ref } from 'react';
|
|
14
|
+
|
|
15
|
+
/** The wrapper props both layout primitives share, ref included. */
|
|
16
|
+
interface LayoutElementProps {
|
|
17
|
+
readonly className?: string;
|
|
18
|
+
readonly style?: CSSProperties;
|
|
19
|
+
/** A consumer ref for the wrapper element itself. */
|
|
20
|
+
readonly ref?: Ref<HTMLDivElement>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PageHeaderProps {
|
|
24
|
+
/** The screen's title. It is the `<h1>`'s accessible name, VERBATIM. */
|
|
25
|
+
readonly title: string;
|
|
26
|
+
/** The screen's existing nav-section label, rendered ABOVE the h1 — never inside it. */
|
|
27
|
+
readonly eyebrow?: string;
|
|
28
|
+
readonly description?: string;
|
|
29
|
+
readonly actions?: ReactNode;
|
|
30
|
+
/**
|
|
31
|
+
* A consumer ref for the `<h1>`. Published so a caller that wants to move
|
|
32
|
+
* focus to the new screen's title — after a route change, say — has the
|
|
33
|
+
* element without querying for it; the heading is focusable for that reason.
|
|
34
|
+
*/
|
|
35
|
+
readonly titleRef?: Ref<HTMLHeadingElement>;
|
|
36
|
+
/**
|
|
37
|
+
* An id for the `<h1>`, not for the `<header>` root. The heading is the element
|
|
38
|
+
* a caller has a reason to address — an `aria-labelledby` pointing at it names
|
|
39
|
+
* the region from the screen's own title — and it is the other way to reach it
|
|
40
|
+
* as a focus target.
|
|
41
|
+
*/
|
|
42
|
+
readonly id?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function PageHeader({
|
|
46
|
+
title,
|
|
47
|
+
eyebrow,
|
|
48
|
+
description,
|
|
49
|
+
actions,
|
|
50
|
+
titleRef,
|
|
51
|
+
id,
|
|
52
|
+
}: PageHeaderProps): ReactElement {
|
|
53
|
+
return (
|
|
54
|
+
<header className="tai-page-header">
|
|
55
|
+
<div>
|
|
56
|
+
{eyebrow === undefined ? null : <div className="tai-label">{eyebrow}</div>}
|
|
57
|
+
<h1 className="tai-page-title" tabIndex={-1} ref={titleRef} id={id}>
|
|
58
|
+
{title}
|
|
59
|
+
</h1>
|
|
60
|
+
{description === undefined ? null : <p className="tai-page-description">{description}</p>}
|
|
61
|
+
</div>
|
|
62
|
+
{actions === undefined ? null : <div className="tai-page-actions">{actions}</div>}
|
|
63
|
+
</header>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface PageLayoutProps extends LayoutElementProps {
|
|
68
|
+
readonly children: ReactNode;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The screen wrapper: the max-width, the gutters, and the vertical rhythm. */
|
|
72
|
+
export function Page({ children, className, style, ref }: PageLayoutProps): ReactElement {
|
|
73
|
+
return (
|
|
74
|
+
<div
|
|
75
|
+
className={className === undefined ? 'tai-page' : `tai-page ${className}`}
|
|
76
|
+
style={style}
|
|
77
|
+
ref={ref}
|
|
78
|
+
>
|
|
79
|
+
{children}
|
|
80
|
+
</div>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface StackProps extends LayoutElementProps {
|
|
85
|
+
readonly children: ReactNode;
|
|
86
|
+
/** The gap step; 4 is the default and needs no modifier class. */
|
|
87
|
+
readonly gap?: 2 | 3 | 4 | 6;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The modifier class per non-default gap step; gap 4 is `.tai-stack`'s own gap. */
|
|
91
|
+
const STACK_GAP_CLASS: Record<2 | 3 | 6, string> = {
|
|
92
|
+
2: 'tai-stack-2',
|
|
93
|
+
3: 'tai-stack-3',
|
|
94
|
+
6: 'tai-stack-6',
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/** A vertical flex column on the spacing scale. */
|
|
98
|
+
export function Stack({ children, gap = 4, className, style, ref }: StackProps): ReactElement {
|
|
99
|
+
const classes = ['tai-stack'];
|
|
100
|
+
if (gap !== 4) classes.push(STACK_GAP_CLASS[gap]);
|
|
101
|
+
if (className !== undefined) classes.push(className);
|
|
102
|
+
return (
|
|
103
|
+
<div className={classes.join(' ')} style={style} ref={ref}>
|
|
104
|
+
{children}
|
|
105
|
+
</div>
|
|
106
|
+
);
|
|
107
|
+
}
|