@elabs-ai/components-ui 4.0.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 +21 -0
- package/README.md +78 -0
- package/dist/index.d.ts +4497 -0
- package/dist/index.js +15192 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/cn.d.ts +10 -0
- package/dist/lib/cn.js +20 -0
- package/dist/lib/cn.js.map +1 -0
- package/package.json +106 -0
- package/src/blocks/sidebar-02/app-sidebar.tsx +160 -0
- package/src/blocks/sidebar-02/logo.tsx +18 -0
- package/src/blocks/sidebar-02/nav-main.tsx +13 -0
- package/src/blocks/sidebar-02/nav-notifications.tsx +7 -0
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +23 -0
- package/src/blocks/sidebar-02/team-switcher.tsx +7 -0
- package/src/blocks/sidebar-04/app-sidebar.tsx +236 -0
- package/src/blocks/sidebar-04/mail-context.tsx +29 -0
- package/src/blocks/sidebar-04/nav-user.tsx +6 -0
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +150 -0
- package/src/blocks/sidebar-05/app-sidebar.tsx +375 -0
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +18 -0
- package/src/blocks/sidebar-05/team-switcher.tsx +6 -0
- package/src/blocks-comparison-table.stories.tsx +165 -0
- package/src/components/accordion/accordion.stories.tsx +71 -0
- package/src/components/accordion/accordion.test.tsx +18 -0
- package/src/components/accordion/accordion.tsx +49 -0
- package/src/components/accordion/index.ts +1 -0
- package/src/components/advanced-group/advanced-group.stories.tsx +143 -0
- package/src/components/advanced-group/advanced-group.test.tsx +81 -0
- package/src/components/advanced-group/advanced-group.tsx +111 -0
- package/src/components/advanced-group/index.ts +2 -0
- package/src/components/alert/alert.stories.tsx +156 -0
- package/src/components/alert/alert.test.tsx +57 -0
- package/src/components/alert/alert.tsx +91 -0
- package/src/components/alert/index.ts +8 -0
- package/src/components/alert-dialog/alert-dialog.stories.tsx +73 -0
- package/src/components/alert-dialog/alert-dialog.tsx +104 -0
- package/src/components/alert-dialog/index.ts +12 -0
- package/src/components/app-shell/app-shell.stories.tsx +61 -0
- package/src/components/app-shell/app-shell.tsx +31 -0
- package/src/components/app-shell/index.ts +1 -0
- package/src/components/app-sidebar/app-sidebar.stories.tsx +121 -0
- package/src/components/app-sidebar/app-sidebar.test.tsx +55 -0
- package/src/components/app-sidebar/app-sidebar.tsx +53 -0
- package/src/components/app-sidebar/index.ts +1 -0
- package/src/components/aspect-ratio/aspect-ratio.stories.tsx +46 -0
- package/src/components/aspect-ratio/aspect-ratio.tsx +3 -0
- package/src/components/aspect-ratio/index.ts +1 -0
- package/src/components/attribution-panel/attribution-panel.stories.tsx +83 -0
- package/src/components/attribution-panel/attribution-panel.test.tsx +123 -0
- package/src/components/attribution-panel/attribution-panel.tsx +191 -0
- package/src/components/attribution-panel/attribution-types.ts +44 -0
- package/src/components/attribution-panel/attributions.generated.ts +1422 -0
- package/src/components/attribution-panel/index.ts +3 -0
- package/src/components/avatar/avatar.stories.tsx +45 -0
- package/src/components/avatar/avatar.test.tsx +13 -0
- package/src/components/avatar/avatar.tsx +46 -0
- package/src/components/avatar/index.ts +1 -0
- package/src/components/badge/badge.stories.tsx +44 -0
- package/src/components/badge/badge.test.tsx +10 -0
- package/src/components/badge/badge.tsx +28 -0
- package/src/components/badge/index.ts +1 -0
- package/src/components/bento-grid/bento-grid.stories.tsx +165 -0
- package/src/components/bento-grid/bento-grid.test.tsx +119 -0
- package/src/components/bento-grid/bento-grid.tsx +339 -0
- package/src/components/bento-grid/index.ts +9 -0
- package/src/components/bounded-number/bounded-number.stories.tsx +118 -0
- package/src/components/bounded-number/bounded-number.test.tsx +73 -0
- package/src/components/bounded-number/bounded-number.tsx +110 -0
- package/src/components/bounded-number/index.ts +1 -0
- package/src/components/breadcrumb/breadcrumb.stories.tsx +47 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +17 -0
- package/src/components/breadcrumb/breadcrumb.tsx +86 -0
- package/src/components/breadcrumb/index.ts +9 -0
- package/src/components/button/button.stories.tsx +70 -0
- package/src/components/button/button.test.tsx +50 -0
- package/src/components/button/button.tsx +63 -0
- package/src/components/button/index.ts +1 -0
- package/src/components/button-group/button-group.stories.tsx +45 -0
- package/src/components/button-group/button-group.tsx +70 -0
- package/src/components/button-group/index.ts +6 -0
- package/src/components/calendar/calendar.stories.tsx +95 -0
- package/src/components/calendar/calendar.test.tsx +140 -0
- package/src/components/calendar/calendar.tsx +70 -0
- package/src/components/calendar/index.ts +1 -0
- package/src/components/card/card.stories.tsx +438 -0
- package/src/components/card/card.test.tsx +153 -0
- package/src/components/card/card.tsx +332 -0
- package/src/components/card/index.ts +13 -0
- package/src/components/carousel/carousel.stories.tsx +102 -0
- package/src/components/carousel/carousel.tsx +225 -0
- package/src/components/carousel/index.ts +8 -0
- package/src/components/change-review/change-review.stories.tsx +288 -0
- package/src/components/change-review/change-review.test.tsx +261 -0
- package/src/components/change-review/change-review.tsx +637 -0
- package/src/components/change-review/index.ts +23 -0
- package/src/components/checkbox/checkbox.stories.tsx +119 -0
- package/src/components/checkbox/checkbox.test.tsx +40 -0
- package/src/components/checkbox/checkbox.tsx +34 -0
- package/src/components/checkbox/index.ts +1 -0
- package/src/components/collapsible/collapsible.stories.tsx +62 -0
- package/src/components/collapsible/collapsible.tsx +22 -0
- package/src/components/collapsible/index.ts +1 -0
- package/src/components/collapsible-panel/index.ts +8 -0
- package/src/components/collapsible-panel/use-collapsible-panel.test.tsx +93 -0
- package/src/components/collapsible-panel/use-collapsible-panel.ts +159 -0
- package/src/components/color-picker/color-picker.stories.tsx +93 -0
- package/src/components/color-picker/color-picker.test.tsx +79 -0
- package/src/components/color-picker/color-picker.tsx +341 -0
- package/src/components/color-picker/index.ts +1 -0
- package/src/components/combobox/combobox.stories.tsx +154 -0
- package/src/components/combobox/combobox.test.tsx +246 -0
- package/src/components/combobox/combobox.tsx +210 -0
- package/src/components/combobox/index.ts +1 -0
- package/src/components/command/command.stories.tsx +371 -0
- package/src/components/command/command.test.tsx +280 -0
- package/src/components/command/command.tsx +301 -0
- package/src/components/command/index.ts +13 -0
- package/src/components/confirm-dialog/confirm-dialog.stories.tsx +183 -0
- package/src/components/confirm-dialog/confirm-dialog.test.tsx +147 -0
- package/src/components/confirm-dialog/confirm-dialog.tsx +146 -0
- package/src/components/confirm-dialog/index.ts +2 -0
- package/src/components/context-menu/context-menu.stories.tsx +70 -0
- package/src/components/context-menu/context-menu.tsx +172 -0
- package/src/components/context-menu/index.ts +16 -0
- package/src/components/copyable-value/copyable-value.stories.tsx +80 -0
- package/src/components/copyable-value/copyable-value.test.tsx +128 -0
- package/src/components/copyable-value/copyable-value.tsx +101 -0
- package/src/components/copyable-value/index.ts +1 -0
- package/src/components/date-picker/date-picker.stories.tsx +42 -0
- package/src/components/date-picker/date-picker.tsx +49 -0
- package/src/components/date-picker/index.ts +1 -0
- package/src/components/date-range-picker/date-range-picker.stories.tsx +151 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +62 -0
- package/src/components/date-range-picker/date-range-picker.tsx +126 -0
- package/src/components/date-range-picker/index.ts +6 -0
- package/src/components/descriptions/descriptions.stories.tsx +43 -0
- package/src/components/descriptions/descriptions.test.tsx +41 -0
- package/src/components/descriptions/descriptions.tsx +95 -0
- package/src/components/descriptions/index.ts +6 -0
- package/src/components/dialog/dialog.stories.tsx +728 -0
- package/src/components/dialog/dialog.test.tsx +285 -0
- package/src/components/dialog/dialog.tsx +340 -0
- package/src/components/dialog/index.ts +18 -0
- package/src/components/dialog/use-dialog-dismiss-guard.test.tsx +80 -0
- package/src/components/dialog/use-dialog-dismiss-guard.ts +109 -0
- package/src/components/drawer/drawer.stories.tsx +76 -0
- package/src/components/drawer/drawer.tsx +85 -0
- package/src/components/drawer/index.ts +11 -0
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +176 -0
- package/src/components/dropdown-menu/dropdown-menu.test.tsx +109 -0
- package/src/components/dropdown-menu/dropdown-menu.tsx +179 -0
- package/src/components/dropdown-menu/index.ts +17 -0
- package/src/components/empty-state/empty-state.stories.tsx +54 -0
- package/src/components/empty-state/empty-state.test.tsx +11 -0
- package/src/components/empty-state/empty-state.tsx +30 -0
- package/src/components/empty-state/index.ts +1 -0
- package/src/components/error-state/error-state.stories.tsx +51 -0
- package/src/components/error-state/error-state.tsx +27 -0
- package/src/components/error-state/index.ts +1 -0
- package/src/components/expand-dialog/expand-dialog.stories.tsx +246 -0
- package/src/components/expand-dialog/expand-dialog.test.tsx +185 -0
- package/src/components/expand-dialog/expand-dialog.tsx +387 -0
- package/src/components/expand-dialog/index.ts +11 -0
- package/src/components/field-row/field-row.stories.tsx +114 -0
- package/src/components/field-row/field-row.test.tsx +188 -0
- package/src/components/field-row/field-row.tsx +115 -0
- package/src/components/field-row/index.ts +1 -0
- package/src/components/file-upload/file-upload.stories.tsx +183 -0
- package/src/components/file-upload/file-upload.test.tsx +169 -0
- package/src/components/file-upload/file-upload.tsx +459 -0
- package/src/components/file-upload/index.ts +13 -0
- package/src/components/form/form.stories.tsx +71 -0
- package/src/components/form/form.tsx +141 -0
- package/src/components/form/index.ts +10 -0
- package/src/components/hover-card/hover-card.stories.tsx +57 -0
- package/src/components/hover-card/hover-card.tsx +25 -0
- package/src/components/hover-card/index.ts +1 -0
- package/src/components/icon-button/icon-button.stories.tsx +108 -0
- package/src/components/icon-button/icon-button.test.tsx +93 -0
- package/src/components/icon-button/icon-button.tsx +139 -0
- package/src/components/icon-button/index.ts +1 -0
- package/src/components/input/index.ts +1 -0
- package/src/components/input/input.stories.tsx +44 -0
- package/src/components/input/input.test.tsx +19 -0
- package/src/components/input/input.tsx +26 -0
- package/src/components/input-group/index.ts +11 -0
- package/src/components/input-group/input-group.stories.tsx +87 -0
- package/src/components/input-group/input-group.test.tsx +48 -0
- package/src/components/input-group/input-group.tsx +170 -0
- package/src/components/input-otp/index.ts +1 -0
- package/src/components/input-otp/input-otp.stories.tsx +66 -0
- package/src/components/input-otp/input-otp.tsx +68 -0
- package/src/components/kbd/index.ts +1 -0
- package/src/components/kbd/kbd.stories.tsx +45 -0
- package/src/components/kbd/kbd.tsx +20 -0
- package/src/components/key-value-editor/index.ts +1 -0
- package/src/components/key-value-editor/key-value-editor.stories.tsx +130 -0
- package/src/components/key-value-editor/key-value-editor.test.tsx +231 -0
- package/src/components/key-value-editor/key-value-editor.tsx +263 -0
- package/src/components/label/index.ts +1 -0
- package/src/components/label/label.stories.tsx +35 -0
- package/src/components/label/label.tsx +19 -0
- package/src/components/link-preview/index.ts +2 -0
- package/src/components/link-preview/link-preview.stories.tsx +178 -0
- package/src/components/link-preview/link-preview.test.tsx +123 -0
- package/src/components/link-preview/link-preview.tsx +194 -0
- package/src/components/list-editor/index.ts +1 -0
- package/src/components/list-editor/list-editor.stories.tsx +119 -0
- package/src/components/list-editor/list-editor.test.tsx +96 -0
- package/src/components/list-editor/list-editor.tsx +192 -0
- package/src/components/loading-state/index.ts +1 -0
- package/src/components/loading-state/loading-state.stories.tsx +38 -0
- package/src/components/loading-state/loading-state.tsx +26 -0
- package/src/components/locale-provider/index.ts +8 -0
- package/src/components/locale-provider/locale-provider.stories.tsx +83 -0
- package/src/components/locale-provider/locale-provider.test.tsx +238 -0
- package/src/components/locale-provider/locale-provider.tsx +187 -0
- package/src/components/locale-provider/messages.ts +300 -0
- package/src/components/match-highlight/index.ts +7 -0
- package/src/components/match-highlight/match-highlight.stories.tsx +146 -0
- package/src/components/match-highlight/match-highlight.test.tsx +202 -0
- package/src/components/match-highlight/match-highlight.tsx +145 -0
- package/src/components/mention-input/index.ts +28 -0
- package/src/components/mention-input/mention-input-mirror.stories.tsx +171 -0
- package/src/components/mention-input/mention-input.stories.tsx +463 -0
- package/src/components/mention-input/mention-input.test.tsx +617 -0
- package/src/components/mention-input/mention-input.tsx +1102 -0
- package/src/components/mention-input/mention-value.test.ts +253 -0
- package/src/components/mention-input/mention-value.ts +193 -0
- package/src/components/menubar/index.ts +17 -0
- package/src/components/menubar/menubar.stories.tsx +67 -0
- package/src/components/menubar/menubar.tsx +198 -0
- package/src/components/metric-card/index.ts +1 -0
- package/src/components/metric-card/metric-card.stories.tsx +224 -0
- package/src/components/metric-card/metric-card.test.tsx +198 -0
- package/src/components/metric-card/metric-card.tsx +229 -0
- package/src/components/model-picker/index.ts +11 -0
- package/src/components/model-picker/model-picker-state.ts +85 -0
- package/src/components/model-picker/model-picker.stories.tsx +128 -0
- package/src/components/model-picker/model-picker.test.tsx +241 -0
- package/src/components/model-picker/model-picker.tsx +270 -0
- package/src/components/nav-main/index.ts +1 -0
- package/src/components/nav-main/nav-main.tsx +147 -0
- package/src/components/nav-notifications/index.ts +5 -0
- package/src/components/nav-notifications/nav-notifications.test.tsx +15 -0
- package/src/components/nav-notifications/nav-notifications.tsx +70 -0
- package/src/components/nav-user/index.ts +1 -0
- package/src/components/nav-user/nav-user.test.tsx +29 -0
- package/src/components/nav-user/nav-user.tsx +114 -0
- package/src/components/navigation-menu/index.ts +10 -0
- package/src/components/navigation-menu/navigation-menu.stories.tsx +92 -0
- package/src/components/navigation-menu/navigation-menu.tsx +99 -0
- package/src/components/number-input/index.ts +1 -0
- package/src/components/number-input/number-input.stories.tsx +120 -0
- package/src/components/number-input/number-input.test.tsx +96 -0
- package/src/components/number-input/number-input.tsx +219 -0
- package/src/components/page-shell/index.ts +1 -0
- package/src/components/page-shell/page-shell.stories.tsx +105 -0
- package/src/components/page-shell/page-shell.test.tsx +57 -0
- package/src/components/page-shell/page-shell.tsx +62 -0
- package/src/components/pagination/index.ts +9 -0
- package/src/components/pagination/pagination.stories.tsx +57 -0
- package/src/components/pagination/pagination.tsx +77 -0
- package/src/components/popover/index.ts +1 -0
- package/src/components/popover/popover.stories.tsx +28 -0
- package/src/components/popover/popover.test.tsx +16 -0
- package/src/components/popover/popover.tsx +29 -0
- package/src/components/progress/index.ts +1 -0
- package/src/components/progress/progress.stories.tsx +80 -0
- package/src/components/progress/progress.test.tsx +35 -0
- package/src/components/progress/progress.tsx +50 -0
- package/src/components/radio-group/index.ts +1 -0
- package/src/components/radio-group/radio-group.stories.tsx +75 -0
- package/src/components/radio-group/radio-group.test.tsx +16 -0
- package/src/components/radio-group/radio-group.tsx +33 -0
- package/src/components/rating/index.ts +1 -0
- package/src/components/rating/rating.stories.tsx +111 -0
- package/src/components/rating/rating.test.tsx +41 -0
- package/src/components/rating/rating.tsx +226 -0
- package/src/components/resizable/index.ts +1 -0
- package/src/components/resizable/resizable.stories.tsx +25 -0
- package/src/components/resizable/resizable.tsx +40 -0
- package/src/components/reveal/index.ts +7 -0
- package/src/components/reveal/reveal.stories.tsx +90 -0
- package/src/components/reveal/reveal.test.tsx +43 -0
- package/src/components/reveal/reveal.tsx +95 -0
- package/src/components/revision-timeline/index.ts +10 -0
- package/src/components/revision-timeline/revision-timeline.stories.tsx +785 -0
- package/src/components/revision-timeline/revision-timeline.test.tsx +509 -0
- package/src/components/revision-timeline/revision-timeline.tsx +1107 -0
- package/src/components/scroll-area/index.ts +1 -0
- package/src/components/scroll-area/scroll-area.stories.tsx +45 -0
- package/src/components/scroll-area/scroll-area.tsx +43 -0
- package/src/components/section-header/index.ts +1 -0
- package/src/components/section-header/section-header.stories.tsx +20 -0
- package/src/components/section-header/section-header.tsx +36 -0
- package/src/components/segmented-field/index.ts +5 -0
- package/src/components/segmented-field/segmented-field.stories.tsx +121 -0
- package/src/components/segmented-field/segmented-field.test.tsx +163 -0
- package/src/components/segmented-field/segmented-field.tsx +235 -0
- package/src/components/select/index.ts +10 -0
- package/src/components/select/select.stories.tsx +106 -0
- package/src/components/select/select.test.tsx +98 -0
- package/src/components/select/select.tsx +230 -0
- package/src/components/separator/index.ts +2 -0
- package/src/components/separator/separator.stories.tsx +87 -0
- package/src/components/separator/separator.tsx +51 -0
- package/src/components/sheet/index.ts +12 -0
- package/src/components/sheet/sheet.stories.tsx +65 -0
- package/src/components/sheet/sheet.tsx +109 -0
- package/src/components/sidebar/index.ts +27 -0
- package/src/components/sidebar/sidebar.stories.tsx +69 -0
- package/src/components/sidebar/sidebar.test.tsx +43 -0
- package/src/components/sidebar/sidebar.tsx +688 -0
- package/src/components/skeleton/index.ts +1 -0
- package/src/components/skeleton/skeleton.stories.tsx +28 -0
- package/src/components/skeleton/skeleton.tsx +15 -0
- package/src/components/slider/index.ts +1 -0
- package/src/components/slider/slider.stories.tsx +211 -0
- package/src/components/slider/slider.test.tsx +168 -0
- package/src/components/slider/slider.tsx +136 -0
- package/src/components/slider-number/index.ts +1 -0
- package/src/components/slider-number/slider-number.stories.tsx +123 -0
- package/src/components/slider-number/slider-number.test.tsx +165 -0
- package/src/components/slider-number/slider-number.tsx +191 -0
- package/src/components/sonner/index.ts +1 -0
- package/src/components/sonner/sonner.stories.tsx +124 -0
- package/src/components/sonner/sonner.test.tsx +76 -0
- package/src/components/sonner/sonner.tsx +101 -0
- package/src/components/spinner/index.ts +1 -0
- package/src/components/spinner/spinner.stories.tsx +27 -0
- package/src/components/spinner/spinner.tsx +19 -0
- package/src/components/split-panel/index.ts +1 -0
- package/src/components/split-panel/split-panel.stories.tsx +60 -0
- package/src/components/split-panel/split-panel.test.tsx +38 -0
- package/src/components/split-panel/split-panel.tsx +95 -0
- package/src/components/state-panel/index.ts +1 -0
- package/src/components/state-panel/state-panel.stories.tsx +80 -0
- package/src/components/state-panel/state-panel.test.tsx +64 -0
- package/src/components/state-panel/state-panel.tsx +167 -0
- package/src/components/status-badge/index.ts +18 -0
- package/src/components/status-badge/status-badge.stories.tsx +132 -0
- package/src/components/status-badge/status-badge.test.tsx +189 -0
- package/src/components/status-badge/status-badge.tsx +330 -0
- package/src/components/switch/index.ts +1 -0
- package/src/components/switch/switch.stories.tsx +58 -0
- package/src/components/switch/switch.test.tsx +12 -0
- package/src/components/switch/switch.tsx +29 -0
- package/src/components/table/index.ts +10 -0
- package/src/components/table/table.stories.tsx +53 -0
- package/src/components/table/table.test.tsx +17 -0
- package/src/components/table/table.tsx +106 -0
- package/src/components/tabs/index.ts +1 -0
- package/src/components/tabs/tabs.stories.tsx +267 -0
- package/src/components/tabs/tabs.test.tsx +94 -0
- package/src/components/tabs/tabs.tsx +170 -0
- package/src/components/tag-input/index.ts +1 -0
- package/src/components/tag-input/tag-input.stories.tsx +120 -0
- package/src/components/tag-input/tag-input.test.tsx +97 -0
- package/src/components/tag-input/tag-input.tsx +253 -0
- package/src/components/team-switcher/index.ts +1 -0
- package/src/components/team-switcher/team-switcher.test.tsx +39 -0
- package/src/components/team-switcher/team-switcher.tsx +100 -0
- package/src/components/textarea/index.ts +1 -0
- package/src/components/textarea/textarea.stories.tsx +42 -0
- package/src/components/textarea/textarea.tsx +24 -0
- package/src/components/theme-switcher/index.ts +2 -0
- package/src/components/theme-switcher/theme-switcher.stories.tsx +146 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +253 -0
- package/src/components/theme-switcher/theme-switcher.tsx +280 -0
- package/src/components/theme-switcher/use-theme-transition.ts +62 -0
- package/src/components/timeline/index.ts +13 -0
- package/src/components/timeline/timeline.stories.tsx +99 -0
- package/src/components/timeline/timeline.test.tsx +196 -0
- package/src/components/timeline/timeline.tsx +188 -0
- package/src/components/toggle/index.ts +1 -0
- package/src/components/toggle/toggle.stories.tsx +116 -0
- package/src/components/toggle/toggle.test.tsx +119 -0
- package/src/components/toggle/toggle.tsx +64 -0
- package/src/components/toggle-group/index.ts +1 -0
- package/src/components/toggle-group/toggle-group.stories.tsx +110 -0
- package/src/components/toggle-group/toggle-group.tsx +59 -0
- package/src/components/toolbar/index.ts +14 -0
- package/src/components/toolbar/toolbar.stories.tsx +126 -0
- package/src/components/toolbar/toolbar.test.tsx +73 -0
- package/src/components/toolbar/toolbar.tsx +177 -0
- package/src/components/tooltip/index.ts +1 -0
- package/src/components/tooltip/tooltip.stories.tsx +67 -0
- package/src/components/tooltip/tooltip.tsx +27 -0
- package/src/components/top-nav/index.ts +1 -0
- package/src/components/top-nav/top-nav.stories.tsx +18 -0
- package/src/components/top-nav/top-nav.tsx +34 -0
- package/src/components/transfer/index.ts +2 -0
- package/src/components/transfer/transfer.stories.tsx +162 -0
- package/src/components/transfer/transfer.test.tsx +177 -0
- package/src/components/transfer/transfer.tsx +481 -0
- package/src/components/tree/index.ts +6 -0
- package/src/components/tree/tree.stories.tsx +618 -0
- package/src/components/tree/tree.test.tsx +699 -0
- package/src/components/tree/tree.tsx +1149 -0
- package/src/components/tree/use-tree-keyboard.ts +314 -0
- package/src/components/tree-select/index.ts +1 -0
- package/src/components/tree-select/tree-select.stories.tsx +127 -0
- package/src/components/tree-select/tree-select.test.tsx +40 -0
- package/src/components/tree-select/tree-select.tsx +137 -0
- package/src/components/typography/index.ts +31 -0
- package/src/components/typography/prose.test.tsx +48 -0
- package/src/components/typography/prose.tsx +194 -0
- package/src/components/typography/typography.stories.tsx +244 -0
- package/src/components/typography/typography.test.tsx +140 -0
- package/src/components/typography/typography.tsx +126 -0
- package/src/components/view-toolbar/index.ts +10 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +342 -0
- package/src/components/view-toolbar/view-toolbar.test.tsx +262 -0
- package/src/components/view-toolbar/view-toolbar.tsx +320 -0
- package/src/components/virtual-select/index.ts +2 -0
- package/src/components/virtual-select/use-virtual-listbox.ts +132 -0
- package/src/components/virtual-select/virtual-select.stories.tsx +203 -0
- package/src/components/virtual-select/virtual-select.test.tsx +106 -0
- package/src/components/virtual-select/virtual-select.tsx +361 -0
- package/src/components/wizard/index.ts +12 -0
- package/src/components/wizard/wizard.stories.tsx +148 -0
- package/src/components/wizard/wizard.test.tsx +67 -0
- package/src/components/wizard/wizard.tsx +354 -0
- package/src/decoration.stories.tsx +236 -0
- package/src/index.ts +174 -0
- package/src/lib/cn.test.ts +39 -0
- package/src/lib/cn.ts +31 -0
- package/src/lib/compact-number.ts +61 -0
- package/src/lib/document-address.test.ts +75 -0
- package/src/lib/document-address.ts +265 -0
- package/src/lib/download.ts +93 -0
- package/src/lib/file-icon.test.ts +45 -0
- package/src/lib/file-icon.ts +58 -0
- package/src/lib/file-kind.test.ts +98 -0
- package/src/lib/file-kind.ts +303 -0
- package/src/lib/file-source.test.ts +217 -0
- package/src/lib/file-source.ts +269 -0
- package/src/lib/merge-refs.test.ts +37 -0
- package/src/lib/merge-refs.ts +17 -0
- package/src/lib/streamdown-translations.ts +99 -0
- package/src/lib/use-copy-to-clipboard.ts +75 -0
- package/src/lib/use-mobile.ts +16 -0
- package/src/motion.stories.tsx +266 -0
- package/src/taste-profile.test.tsx +252 -0
- package/src/templates-object-detail-hub.stories.tsx +466 -0
- package/src/templates-screen-states.stories.tsx +306 -0
- package/src/templates-settings.stories.tsx +245 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { type FileSource, normalizeFileSource } from "./file-source";
|
|
4
|
+
|
|
5
|
+
const decode = (buffer: ArrayBuffer) => new TextDecoder().decode(buffer);
|
|
6
|
+
|
|
7
|
+
afterEach(() => {
|
|
8
|
+
vi.restoreAllMocks();
|
|
9
|
+
vi.unstubAllGlobals();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("normalizeFileSource — identity", () => {
|
|
13
|
+
it("reads name and MIME off a File", () => {
|
|
14
|
+
const source: FileSource = {
|
|
15
|
+
kind: "file",
|
|
16
|
+
file: new File(["x"], "Report.PDF", { type: "application/pdf" }),
|
|
17
|
+
};
|
|
18
|
+
expect(normalizeFileSource(source)).toMatchObject({
|
|
19
|
+
name: "Report.PDF",
|
|
20
|
+
mediaType: "application/pdf",
|
|
21
|
+
category: "document",
|
|
22
|
+
extension: "pdf",
|
|
23
|
+
size: 1,
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("infers a File's MIME from its name when the platform reports none", () => {
|
|
28
|
+
const file = new File(["a,b"], "rows.csv", { type: "" });
|
|
29
|
+
expect(normalizeFileSource({ kind: "file", file })).toMatchObject({
|
|
30
|
+
mediaType: "text/csv",
|
|
31
|
+
category: "data",
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("prefers an explicit mediaType over the Blob's own", () => {
|
|
36
|
+
const blob = new Blob(["x"], { type: "application/octet-stream" });
|
|
37
|
+
const resolved = normalizeFileSource({
|
|
38
|
+
kind: "blob",
|
|
39
|
+
blob,
|
|
40
|
+
name: "page.html",
|
|
41
|
+
mediaType: "text/html",
|
|
42
|
+
});
|
|
43
|
+
expect(resolved).toMatchObject({ mediaType: "text/html", category: "code" });
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("derives a name from the URL path when the caller gives none", () => {
|
|
47
|
+
expect(
|
|
48
|
+
normalizeFileSource({ kind: "url", url: "https://x.test/a/b/notes%20v2.md" }),
|
|
49
|
+
).toMatchObject({
|
|
50
|
+
name: "notes v2.md",
|
|
51
|
+
category: "text",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("ignores a URL query and hash when deriving the name", () => {
|
|
56
|
+
expect(
|
|
57
|
+
normalizeFileSource({ kind: "url", url: "https://x.test/chart.svg?v=2#top" }),
|
|
58
|
+
).toMatchObject({
|
|
59
|
+
name: "chart.svg",
|
|
60
|
+
category: "image",
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("never produces an empty name", () => {
|
|
65
|
+
expect(normalizeFileSource({ kind: "url", url: "https://x.test/" }).name).toBe("file");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("carries alt through", () => {
|
|
69
|
+
const file = new File([""], "a.png", { type: "image/png" });
|
|
70
|
+
expect(normalizeFileSource({ kind: "file", file, alt: "A bar chart" }).alt).toBe("A bar chart");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("reports size only where the platform already knows it", () => {
|
|
74
|
+
const buffer = new TextEncoder().encode("hello").buffer as ArrayBuffer;
|
|
75
|
+
expect(
|
|
76
|
+
normalizeFileSource({
|
|
77
|
+
kind: "buffer",
|
|
78
|
+
buffer,
|
|
79
|
+
name: "a.bin",
|
|
80
|
+
mediaType: "application/octet-stream",
|
|
81
|
+
}).size,
|
|
82
|
+
).toBe(5);
|
|
83
|
+
// A text source would need an encode pass and a url source a fetch — neither is free.
|
|
84
|
+
expect(
|
|
85
|
+
normalizeFileSource({ kind: "text", text: "hello", name: "a.txt" }).size,
|
|
86
|
+
).toBeUndefined();
|
|
87
|
+
expect(normalizeFileSource({ kind: "url", url: "https://x.test/a.txt" }).size).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("does not read anything until asked", async () => {
|
|
91
|
+
const fetchSpy = vi.fn();
|
|
92
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
93
|
+
normalizeFileSource({ kind: "url", url: "https://x.test/a.txt" });
|
|
94
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("normalizeFileSource — reading", () => {
|
|
99
|
+
it("reads every variant as bytes and as text", async () => {
|
|
100
|
+
const sources: FileSource[] = [
|
|
101
|
+
{ kind: "file", file: new File(["hello"], "a.txt", { type: "text/plain" }) },
|
|
102
|
+
{ kind: "blob", blob: new Blob(["hello"]), name: "a.txt" },
|
|
103
|
+
{
|
|
104
|
+
kind: "buffer",
|
|
105
|
+
buffer: new TextEncoder().encode("hello").buffer as ArrayBuffer,
|
|
106
|
+
name: "a.txt",
|
|
107
|
+
mediaType: "text/plain",
|
|
108
|
+
},
|
|
109
|
+
{ kind: "text", text: "hello", name: "a.txt" },
|
|
110
|
+
];
|
|
111
|
+
for (const source of sources) {
|
|
112
|
+
const resolved = normalizeFileSource(source);
|
|
113
|
+
expect(decode(await resolved.bytes()), source.kind).toBe("hello");
|
|
114
|
+
expect(await resolved.text(), source.kind).toBe("hello");
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("passes init through to fetch, so an authenticated file is reachable", async () => {
|
|
119
|
+
const fetchSpy = vi.fn().mockResolvedValue(new Response("secret", { status: 200 }));
|
|
120
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
121
|
+
|
|
122
|
+
const init: RequestInit = { headers: { Authorization: "Bearer t" }, credentials: "include" };
|
|
123
|
+
const resolved = normalizeFileSource({ kind: "url", url: "https://x.test/a.txt", init });
|
|
124
|
+
expect(await resolved.text()).toBe("secret");
|
|
125
|
+
|
|
126
|
+
expect(fetchSpy).toHaveBeenCalledWith(
|
|
127
|
+
"https://x.test/a.txt",
|
|
128
|
+
expect.objectContaining({ headers: { Authorization: "Bearer t" }, credentials: "include" }),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("names the file in a failed fetch, so the error is actionable", async () => {
|
|
133
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("", { status: 404 })));
|
|
134
|
+
const resolved = normalizeFileSource({ kind: "url", url: "https://x.test/missing.pdf" });
|
|
135
|
+
await expect(resolved.bytes()).rejects.toThrow(/missing\.pdf.*404/);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("fetches once across repeated reads", async () => {
|
|
139
|
+
const fetchSpy = vi.fn().mockImplementation(() => Promise.resolve(new Response("hello")));
|
|
140
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
141
|
+
|
|
142
|
+
const resolved = normalizeFileSource({ kind: "url", url: "https://x.test/a.txt" });
|
|
143
|
+
await Promise.all([resolved.bytes(), resolved.bytes(), resolved.text()]);
|
|
144
|
+
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("does not cache a failure, so a retry re-runs the read", async () => {
|
|
148
|
+
const fetchSpy = vi
|
|
149
|
+
.fn()
|
|
150
|
+
.mockRejectedValueOnce(new Error("offline"))
|
|
151
|
+
.mockResolvedValueOnce(new Response("hello"));
|
|
152
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
153
|
+
|
|
154
|
+
const resolved = normalizeFileSource({ kind: "url", url: "https://x.test/a.txt" });
|
|
155
|
+
await expect(resolved.bytes()).rejects.toThrow("offline");
|
|
156
|
+
expect(decode(await resolved.bytes())).toBe("hello");
|
|
157
|
+
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("rejects an already-aborted read without touching the network", async () => {
|
|
161
|
+
const fetchSpy = vi.fn();
|
|
162
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
163
|
+
const resolved = normalizeFileSource({ kind: "url", url: "https://x.test/a.txt" });
|
|
164
|
+
await expect(resolved.bytes(AbortSignal.abort())).rejects.toBeDefined();
|
|
165
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("normalizeFileSource — url()", () => {
|
|
170
|
+
it("returns a public remote URL unchanged, so the element streams it", async () => {
|
|
171
|
+
const fetchSpy = vi.fn();
|
|
172
|
+
vi.stubGlobal("fetch", fetchSpy);
|
|
173
|
+
const resolved = normalizeFileSource({ kind: "url", url: "https://x.test/a.png" });
|
|
174
|
+
expect(await resolved.url()).toBe("https://x.test/a.png");
|
|
175
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("mints an object URL for a url source carrying init — the element cannot replay auth headers", async () => {
|
|
179
|
+
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("bytes")));
|
|
180
|
+
const createObjectURL = vi.fn().mockReturnValue("blob:mock");
|
|
181
|
+
vi.stubGlobal("URL", Object.assign(URL, { createObjectURL, revokeObjectURL: vi.fn() }));
|
|
182
|
+
|
|
183
|
+
const resolved = normalizeFileSource({
|
|
184
|
+
kind: "url",
|
|
185
|
+
url: "https://x.test/a.png",
|
|
186
|
+
init: { headers: { Authorization: "Bearer t" } },
|
|
187
|
+
});
|
|
188
|
+
expect(await resolved.url()).toBe("blob:mock");
|
|
189
|
+
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("mints one object URL per reader and revokes it exactly once", async () => {
|
|
193
|
+
const createObjectURL = vi.fn().mockReturnValue("blob:mock");
|
|
194
|
+
const revokeObjectURL = vi.fn();
|
|
195
|
+
vi.stubGlobal("URL", Object.assign(URL, { createObjectURL, revokeObjectURL }));
|
|
196
|
+
|
|
197
|
+
const file = new File(["x"], "a.png", { type: "image/png" });
|
|
198
|
+
const resolved = normalizeFileSource({ kind: "file", file });
|
|
199
|
+
|
|
200
|
+
expect(await resolved.url()).toBe("blob:mock");
|
|
201
|
+
expect(await resolved.url()).toBe("blob:mock");
|
|
202
|
+
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
|
203
|
+
|
|
204
|
+
resolved.revoke();
|
|
205
|
+
resolved.revoke();
|
|
206
|
+
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("revoke() is safe before any url() call", () => {
|
|
210
|
+
const revokeObjectURL = vi.fn();
|
|
211
|
+
vi.stubGlobal("URL", Object.assign(URL, { revokeObjectURL }));
|
|
212
|
+
expect(() =>
|
|
213
|
+
normalizeFileSource({ kind: "text", text: "x", name: "a.txt" }).revoke(),
|
|
214
|
+
).not.toThrow();
|
|
215
|
+
expect(revokeObjectURL).not.toHaveBeenCalled();
|
|
216
|
+
});
|
|
217
|
+
});
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One input model for "a file", whatever the app happens to be holding.
|
|
3
|
+
*
|
|
4
|
+
* The library previously had three: a `File` (upload), a `string` of content
|
|
5
|
+
* (chat assets), and a bare URL (attachments) — so every surface that wanted to
|
|
6
|
+
* render a file first had to guess which of the three it had. {@link FileSource}
|
|
7
|
+
* is the union all of them collapse into, and {@link normalizeFileSource} turns
|
|
8
|
+
* any member into one uniform reader.
|
|
9
|
+
*
|
|
10
|
+
* Two things this fixes beyond the merge:
|
|
11
|
+
*
|
|
12
|
+
* - **Authenticated remote files work.** The `url` variant carries
|
|
13
|
+
* `init?: RequestInit`, so headers, credentials and an abort signal reach the
|
|
14
|
+
* fetch. A viewer that can only take a naked URL cannot open anything behind
|
|
15
|
+
* auth, which is most enterprise content.
|
|
16
|
+
* - **Alt text has a home.** Every binary variant takes `alt`, so an image or a
|
|
17
|
+
* rendered page can be described without a parallel prop channel.
|
|
18
|
+
*
|
|
19
|
+
* Pure DOM/platform mechanics: no React, no other deps. SSR-safe to import;
|
|
20
|
+
* only {@link ResolvedFileSource.url} needs a browser, and it says so.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { type FileCategory, mediaTypeFromName, resolveFileKind } from "./file-kind";
|
|
24
|
+
|
|
25
|
+
/** Any file the library can be handed. Discriminated on `kind`. */
|
|
26
|
+
export type FileSource =
|
|
27
|
+
/** A user-picked file. Name and MIME come from the platform. */
|
|
28
|
+
| { kind: "file"; file: File; alt?: string }
|
|
29
|
+
/** In-memory bytes that already have a `Blob` wrapper (a generated export, a decoded attachment). */
|
|
30
|
+
| { kind: "blob"; blob: Blob; name: string; mediaType?: string; alt?: string }
|
|
31
|
+
/**
|
|
32
|
+
* A remote file. `init` is passed straight to `fetch` — this is how auth
|
|
33
|
+
* headers, `credentials` and an `AbortSignal` reach the request.
|
|
34
|
+
*/
|
|
35
|
+
| {
|
|
36
|
+
kind: "url";
|
|
37
|
+
url: string;
|
|
38
|
+
name?: string;
|
|
39
|
+
mediaType?: string;
|
|
40
|
+
alt?: string;
|
|
41
|
+
init?: RequestInit;
|
|
42
|
+
}
|
|
43
|
+
/** Raw bytes with no wrapper (a worker result, a WASM output). */
|
|
44
|
+
| { kind: "buffer"; buffer: ArrayBuffer; name: string; mediaType: string; alt?: string }
|
|
45
|
+
/** Content the app already has as a string (an agent's markdown, a code snippet). */
|
|
46
|
+
| { kind: "text"; text: string; name: string; mediaType?: string };
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A {@link FileSource} with its identity resolved and its bytes reachable the
|
|
50
|
+
* same way regardless of where they came from.
|
|
51
|
+
*
|
|
52
|
+
* Reads are memoized on success, so `text()` after `bytes()` costs nothing and a
|
|
53
|
+
* remote file is fetched once. A failed read is NOT cached — a retry re-runs it.
|
|
54
|
+
*/
|
|
55
|
+
export interface ResolvedFileSource {
|
|
56
|
+
/** Display name, always non-empty. Derived from the URL path when the caller gave none. */
|
|
57
|
+
readonly name: string;
|
|
58
|
+
/** Best-known MIME, resolved from the declared type then the extension. */
|
|
59
|
+
readonly mediaType: string;
|
|
60
|
+
/** Coarse shape, for icons/labels/fallbacks. Not a renderer routing key. */
|
|
61
|
+
readonly category: FileCategory;
|
|
62
|
+
/** Lowercased extension without the dot, or `""`. */
|
|
63
|
+
readonly extension: string;
|
|
64
|
+
/** Author-supplied description, for images and rendered pages. */
|
|
65
|
+
readonly alt?: string;
|
|
66
|
+
/** Byte length when known up front. `undefined` for `text` and un-fetched `url` sources. */
|
|
67
|
+
readonly size?: number;
|
|
68
|
+
|
|
69
|
+
/** The full contents as bytes. */
|
|
70
|
+
bytes(signal?: AbortSignal): Promise<ArrayBuffer>;
|
|
71
|
+
/** The full contents decoded as UTF-8. */
|
|
72
|
+
text(signal?: AbortSignal): Promise<string>;
|
|
73
|
+
/**
|
|
74
|
+
* A URL usable as an `<img>`/`<video>`/`<iframe>` src.
|
|
75
|
+
*
|
|
76
|
+
* A public `url` source returns its own URL unchanged, so the browser streams
|
|
77
|
+
* it and no full read happens. Every other source (and any `url` source with
|
|
78
|
+
* an `init`, which the element could not replay) mints an object URL — release
|
|
79
|
+
* it with {@link revoke}. Browser only: rejects where `URL.createObjectURL`
|
|
80
|
+
* does not exist.
|
|
81
|
+
*/
|
|
82
|
+
url(signal?: AbortSignal): Promise<string>;
|
|
83
|
+
/** Release any object URL this reader minted. Idempotent; safe to call unconditionally. */
|
|
84
|
+
revoke(): void;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Best-effort filename from a URL path. Never empty. */
|
|
88
|
+
function nameFromUrl(url: string): string {
|
|
89
|
+
try {
|
|
90
|
+
// A relative URL still needs a base to parse; the base is never used.
|
|
91
|
+
const parsed = new URL(url, "http://localhost");
|
|
92
|
+
const last = parsed.pathname.split("/").filter(Boolean).pop();
|
|
93
|
+
return last ? decodeURIComponent(last) : "file";
|
|
94
|
+
} catch {
|
|
95
|
+
// Unparseable: strip the query/hash by hand rather than give up. Note this
|
|
96
|
+
// runs ONLY on a parse failure — running it as a fallback for an empty
|
|
97
|
+
// pathname would return the host ("x.test" for "https://x.test/").
|
|
98
|
+
const stripped = url.split(/[?#]/)[0] ?? "";
|
|
99
|
+
const last = stripped.split("/").filter(Boolean).pop();
|
|
100
|
+
return last && last.length > 0 ? last : "file";
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `Blob`/`File` → bytes, feature-detecting `Blob.prototype.arrayBuffer`.
|
|
106
|
+
*
|
|
107
|
+
* jsdom does not implement it (nor `Blob.prototype.text`), and this module is
|
|
108
|
+
* reached from every downstream package's jsdom tests — so per the note in
|
|
109
|
+
* `packages/ui/vitest.setup.ts`, the detection lives HERE rather than in a stub
|
|
110
|
+
* that would only fix this package's own suite. `FileReader` is the fallback:
|
|
111
|
+
* jsdom implements it, and it is the pre-2020 browser path anyway.
|
|
112
|
+
*/
|
|
113
|
+
function blobToArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
|
|
114
|
+
if (typeof blob.arrayBuffer === "function") return blob.arrayBuffer();
|
|
115
|
+
|
|
116
|
+
if (typeof FileReader !== "function") {
|
|
117
|
+
return Promise.reject(
|
|
118
|
+
new Error("Cannot read file bytes: no Blob.arrayBuffer and no FileReader."),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return new Promise<ArrayBuffer>((resolve, reject) => {
|
|
122
|
+
const reader = new FileReader();
|
|
123
|
+
reader.onload = () => resolve(reader.result as ArrayBuffer);
|
|
124
|
+
reader.onerror = () => reject(reader.error ?? new Error("Could not read the file."));
|
|
125
|
+
reader.readAsArrayBuffer(blob);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The declared name/MIME for a source, before extension inference. */
|
|
130
|
+
function identify(source: FileSource): { name: string; declaredMediaType?: string } {
|
|
131
|
+
switch (source.kind) {
|
|
132
|
+
case "file":
|
|
133
|
+
return { name: source.file.name || "file", declaredMediaType: source.file.type };
|
|
134
|
+
case "blob":
|
|
135
|
+
return { name: source.name, declaredMediaType: source.mediaType ?? source.blob.type };
|
|
136
|
+
case "buffer":
|
|
137
|
+
return { name: source.name, declaredMediaType: source.mediaType };
|
|
138
|
+
case "text":
|
|
139
|
+
return {
|
|
140
|
+
name: source.name,
|
|
141
|
+
declaredMediaType: source.mediaType ?? mediaTypeFromName(source.name) ?? "text/plain",
|
|
142
|
+
};
|
|
143
|
+
case "url":
|
|
144
|
+
return { name: source.name ?? nameFromUrl(source.url), declaredMediaType: source.mediaType };
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Byte length where the platform already knows it, without reading anything. */
|
|
149
|
+
function knownSize(source: FileSource): number | undefined {
|
|
150
|
+
switch (source.kind) {
|
|
151
|
+
case "file":
|
|
152
|
+
return source.file.size;
|
|
153
|
+
case "blob":
|
|
154
|
+
return source.blob.size;
|
|
155
|
+
case "buffer":
|
|
156
|
+
return source.buffer.byteLength;
|
|
157
|
+
// `text` would need an encode pass, `url` a fetch — neither is free, so we
|
|
158
|
+
// report "unknown" rather than doing work a caller may not want.
|
|
159
|
+
default:
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function assertNotAborted(signal: AbortSignal | undefined): void {
|
|
165
|
+
if (signal?.aborted) throw signal.reason instanceof Error ? signal.reason : new Error("Aborted");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Turn any {@link FileSource} into a uniform reader.
|
|
170
|
+
*
|
|
171
|
+
* Cheap and synchronous — it resolves identity only. Nothing is read, fetched or
|
|
172
|
+
* decoded until a `bytes()` / `text()` / `url()` call.
|
|
173
|
+
*/
|
|
174
|
+
export function normalizeFileSource(source: FileSource): ResolvedFileSource {
|
|
175
|
+
const { name, declaredMediaType } = identify(source);
|
|
176
|
+
const kind = resolveFileKind(name, declaredMediaType);
|
|
177
|
+
const alt = "alt" in source ? source.alt : undefined;
|
|
178
|
+
|
|
179
|
+
let bytesPromise: Promise<ArrayBuffer> | undefined;
|
|
180
|
+
let textPromise: Promise<string> | undefined;
|
|
181
|
+
let objectUrl: string | undefined;
|
|
182
|
+
|
|
183
|
+
async function readBytes(signal?: AbortSignal): Promise<ArrayBuffer> {
|
|
184
|
+
assertNotAborted(signal);
|
|
185
|
+
switch (source.kind) {
|
|
186
|
+
case "file":
|
|
187
|
+
return blobToArrayBuffer(source.file);
|
|
188
|
+
case "blob":
|
|
189
|
+
return blobToArrayBuffer(source.blob);
|
|
190
|
+
case "buffer":
|
|
191
|
+
return source.buffer;
|
|
192
|
+
case "text":
|
|
193
|
+
return new TextEncoder().encode(source.text).buffer as ArrayBuffer;
|
|
194
|
+
case "url": {
|
|
195
|
+
// The caller's own signal (via `init`) still applies; this one is layered
|
|
196
|
+
// on top so a per-read abort works even when `init` carries none.
|
|
197
|
+
const res = await fetch(source.url, {
|
|
198
|
+
...source.init,
|
|
199
|
+
signal: signal ?? source.init?.signal,
|
|
200
|
+
});
|
|
201
|
+
if (!res.ok) {
|
|
202
|
+
throw new Error(`Could not read "${name}": the server responded ${res.status}.`);
|
|
203
|
+
}
|
|
204
|
+
return res.arrayBuffer();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function blobOf(signal?: AbortSignal): Promise<Blob> {
|
|
210
|
+
if (source.kind === "file") return source.file;
|
|
211
|
+
if (source.kind === "blob") return source.blob;
|
|
212
|
+
const buffer = await bytes(signal);
|
|
213
|
+
return new Blob([buffer], { type: kind.mediaType });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function bytes(signal?: AbortSignal): Promise<ArrayBuffer> {
|
|
217
|
+
// Memoize the success only: a rejected read must be retryable.
|
|
218
|
+
bytesPromise ??= readBytes(signal).catch((error: unknown) => {
|
|
219
|
+
bytesPromise = undefined;
|
|
220
|
+
throw error;
|
|
221
|
+
});
|
|
222
|
+
return bytesPromise;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function text(signal?: AbortSignal): Promise<string> {
|
|
226
|
+
if (source.kind === "text") return Promise.resolve(source.text);
|
|
227
|
+
textPromise ??= bytes(signal)
|
|
228
|
+
.then((buffer) => new TextDecoder().decode(buffer))
|
|
229
|
+
.catch((error: unknown) => {
|
|
230
|
+
textPromise = undefined;
|
|
231
|
+
throw error;
|
|
232
|
+
});
|
|
233
|
+
return textPromise;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function url(signal?: AbortSignal): Promise<string> {
|
|
237
|
+
// A public remote file is already a URL an element can stream. One carrying
|
|
238
|
+
// `init` is not: the element cannot replay those headers, so we fetch here.
|
|
239
|
+
if (source.kind === "url" && !source.init) return source.url;
|
|
240
|
+
|
|
241
|
+
if (objectUrl) return objectUrl;
|
|
242
|
+
if (typeof URL === "undefined" || typeof URL.createObjectURL !== "function") {
|
|
243
|
+
throw new Error(
|
|
244
|
+
`Could not create a URL for "${name}": no object-URL support in this environment.`,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
objectUrl = URL.createObjectURL(await blobOf(signal));
|
|
248
|
+
return objectUrl;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function revoke(): void {
|
|
252
|
+
if (!objectUrl) return;
|
|
253
|
+
URL.revokeObjectURL(objectUrl);
|
|
254
|
+
objectUrl = undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
name,
|
|
259
|
+
mediaType: kind.mediaType,
|
|
260
|
+
category: kind.category,
|
|
261
|
+
extension: kind.extension,
|
|
262
|
+
alt,
|
|
263
|
+
size: knownSize(source),
|
|
264
|
+
bytes,
|
|
265
|
+
text,
|
|
266
|
+
url,
|
|
267
|
+
revoke,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createRef, type ForwardedRef } from "react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { mergeRefs } from "./merge-refs";
|
|
4
|
+
|
|
5
|
+
describe("mergeRefs", () => {
|
|
6
|
+
it("writes the node to object refs and calls callback refs", () => {
|
|
7
|
+
const objectRef = createRef<HTMLDivElement>();
|
|
8
|
+
const callbackRef = vi.fn();
|
|
9
|
+
const node = document.createElement("div");
|
|
10
|
+
|
|
11
|
+
mergeRefs<HTMLDivElement>(objectRef, callbackRef)(node);
|
|
12
|
+
|
|
13
|
+
expect(objectRef.current).toBe(node);
|
|
14
|
+
expect(callbackRef).toHaveBeenCalledWith(node);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("propagates unmount (null) to every ref", () => {
|
|
18
|
+
const objectRef = createRef<HTMLDivElement>();
|
|
19
|
+
const callbackRef = vi.fn();
|
|
20
|
+
const merged = mergeRefs<HTMLDivElement>(objectRef, callbackRef);
|
|
21
|
+
|
|
22
|
+
merged(document.createElement("div"));
|
|
23
|
+
merged(null);
|
|
24
|
+
|
|
25
|
+
expect(objectRef.current).toBeNull();
|
|
26
|
+
expect(callbackRef).toHaveBeenLastCalledWith(null);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("skips null/undefined refs — the common `forwardRef` case where no ref was passed", () => {
|
|
30
|
+
const objectRef = createRef<HTMLDivElement>();
|
|
31
|
+
const node = document.createElement("div");
|
|
32
|
+
const forwarded: ForwardedRef<HTMLDivElement> = null;
|
|
33
|
+
|
|
34
|
+
expect(() => mergeRefs<HTMLDivElement>(forwarded, undefined, objectRef)(node)).not.toThrow();
|
|
35
|
+
expect(objectRef.current).toBe(node);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ForwardedRef } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Combine a `forwardRef`-forwarded ref with one or more locally-owned refs so
|
|
5
|
+
* both end up pointing at the same DOM node. Returns a stable callback ref
|
|
6
|
+
* wrapper — memoize the result (`useMemo`) keyed on the forwarded ref so it
|
|
7
|
+
* doesn't detach/reattach on every render.
|
|
8
|
+
*/
|
|
9
|
+
export function mergeRefs<T>(...refs: Array<ForwardedRef<T> | undefined>) {
|
|
10
|
+
return (node: T | null) => {
|
|
11
|
+
for (const ref of refs) {
|
|
12
|
+
if (!ref) continue;
|
|
13
|
+
if (typeof ref === "function") ref(node);
|
|
14
|
+
else ref.current = node;
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Streamdown's own chrome microcopy, resolved through the brand locale seam.
|
|
5
|
+
*
|
|
6
|
+
* Why this lives in `@elabs-ai/components-ui` and not next to the
|
|
7
|
+
* renderer that uses it (#310, ADR 0017): Streamdown draws controls INSIDE the
|
|
8
|
+
* markdown it renders — a code-block copy button, table copy/download menus, a
|
|
9
|
+
* Mermaid toolbar, an external-link interstitial. Those labels live in the
|
|
10
|
+
* dependency, so `pnpm microcopy:check` structurally cannot see them and a
|
|
11
|
+
* `<LocaleProvider>` used to stop dead at the Streamdown boundary. TWO packages
|
|
12
|
+
* now render Streamdown — `@elabs-ai/components-ai` (chat answers,
|
|
13
|
+
* `MarkdownView`) and `@elabs-ai/components-viewer` (a markdown FILE
|
|
14
|
+
* someone opened) — and they may not import each other, so the shared half sits
|
|
15
|
+
* one layer down where both can reach it.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately NO import of `streamdown`, not even a type: this keeps the
|
|
18
|
+
* base package free of a dependency it would otherwise carry for every consumer
|
|
19
|
+
* of every component. The COMPLETENESS of the key list is proved where the
|
|
20
|
+
* dependency actually is — see `packages/ai/src/_streamdown-i18n.ts`, whose
|
|
21
|
+
* compile-time assertion fails the build if a Streamdown upgrade adds a key.
|
|
22
|
+
* That is the right split: the base package owns the wiring, the package that
|
|
23
|
+
* pins the version owns the tripwire.
|
|
24
|
+
*
|
|
25
|
+
* The English defaults in `./components/locale-provider/messages.ts` are
|
|
26
|
+
* byte-identical to Streamdown's own, so this is a no-op for anyone who
|
|
27
|
+
* overrides nothing.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { useMemo } from "react";
|
|
31
|
+
|
|
32
|
+
import { useLocale } from "../components/locale-provider";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Every key of `StreamdownTranslations` as of streamdown@2.5.0.
|
|
36
|
+
*
|
|
37
|
+
* Kept explicit rather than derived from `defaultTranslations`, so the list is
|
|
38
|
+
* readable data here and checkable against the real type over in `ai`.
|
|
39
|
+
*/
|
|
40
|
+
export const STREAMDOWN_TRANSLATION_KEYS = [
|
|
41
|
+
"close",
|
|
42
|
+
"copied",
|
|
43
|
+
"copyCode",
|
|
44
|
+
"copyLink",
|
|
45
|
+
"copyTable",
|
|
46
|
+
"copyTableAsCsv",
|
|
47
|
+
"copyTableAsMarkdown",
|
|
48
|
+
"copyTableAsTsv",
|
|
49
|
+
"downloadDiagram",
|
|
50
|
+
"downloadDiagramAsMmd",
|
|
51
|
+
"downloadDiagramAsPng",
|
|
52
|
+
"downloadDiagramAsSvg",
|
|
53
|
+
"downloadFile",
|
|
54
|
+
"downloadImage",
|
|
55
|
+
"downloadTable",
|
|
56
|
+
"downloadTableAsCsv",
|
|
57
|
+
"downloadTableAsMarkdown",
|
|
58
|
+
"exitFullscreen",
|
|
59
|
+
"externalLinkWarning",
|
|
60
|
+
"imageNotAvailable",
|
|
61
|
+
"mermaidFormatMmd",
|
|
62
|
+
"mermaidFormatPng",
|
|
63
|
+
"mermaidFormatSvg",
|
|
64
|
+
"openExternalLink",
|
|
65
|
+
"openLink",
|
|
66
|
+
"tableFormatCsv",
|
|
67
|
+
"tableFormatMarkdown",
|
|
68
|
+
"tableFormatTsv",
|
|
69
|
+
"viewFullscreen",
|
|
70
|
+
] as const;
|
|
71
|
+
|
|
72
|
+
export type StreamdownTranslationKey = (typeof STREAMDOWN_TRANSLATION_KEYS)[number];
|
|
73
|
+
|
|
74
|
+
/** What Streamdown's `translations` prop takes, expressed without importing it. */
|
|
75
|
+
export type StreamdownTranslationMap = Record<StreamdownTranslationKey, string>;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve every Streamdown label through `t()`.
|
|
79
|
+
*
|
|
80
|
+
* Provider-optional, like `useLocale()` itself: with no `<LocaleProvider>` every
|
|
81
|
+
* key resolves to the shipped English default.
|
|
82
|
+
*
|
|
83
|
+
* Memoization matters — Streamdown puts `translations` into a React context, so
|
|
84
|
+
* a fresh object per render would re-render every code block and table in the
|
|
85
|
+
* document. The map is memoized on `t`, which is stable per locale.
|
|
86
|
+
*/
|
|
87
|
+
export function useStreamdownTranslations(): StreamdownTranslationMap {
|
|
88
|
+
const { t } = useLocale();
|
|
89
|
+
return useMemo(() => {
|
|
90
|
+
const resolved = {} as StreamdownTranslationMap;
|
|
91
|
+
for (const key of STREAMDOWN_TRANSLATION_KEYS) {
|
|
92
|
+
// The `ai.streamdown.*` namespace is kept as-is although this now serves
|
|
93
|
+
// two packages: the keys name STREAMDOWN's chrome, not any one package's,
|
|
94
|
+
// and renaming them would silently drop every consumer's overrides.
|
|
95
|
+
resolved[key] = t(`ai.streamdown.${key}`);
|
|
96
|
+
}
|
|
97
|
+
return resolved;
|
|
98
|
+
}, [t]);
|
|
99
|
+
}
|