@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,188 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import userEvent from "@testing-library/user-event";
|
|
5
|
+
import { useForm } from "react-hook-form";
|
|
6
|
+
import { Input } from "../input";
|
|
7
|
+
import {
|
|
8
|
+
Form,
|
|
9
|
+
FormControl,
|
|
10
|
+
FormDescription,
|
|
11
|
+
FormField,
|
|
12
|
+
FormItem,
|
|
13
|
+
FormLabel,
|
|
14
|
+
FormMessage,
|
|
15
|
+
} from "../form";
|
|
16
|
+
import { FieldRow } from "./field-row";
|
|
17
|
+
|
|
18
|
+
describe("FieldRow", () => {
|
|
19
|
+
it("renders outside any FormProvider / react-hook-form context", () => {
|
|
20
|
+
expect(() =>
|
|
21
|
+
render(
|
|
22
|
+
<FieldRow label="Standalone">
|
|
23
|
+
<Input />
|
|
24
|
+
</FieldRow>,
|
|
25
|
+
),
|
|
26
|
+
).not.toThrow();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("clicking the label focuses the control (native htmlFor/id association)", async () => {
|
|
30
|
+
render(
|
|
31
|
+
<FieldRow label="Name">
|
|
32
|
+
<Input />
|
|
33
|
+
</FieldRow>,
|
|
34
|
+
);
|
|
35
|
+
await userEvent.click(screen.getByText("Name"));
|
|
36
|
+
expect(screen.getByRole("textbox")).toHaveFocus();
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("composes aria-describedby from description + error ids that resolve to the real rendered nodes", () => {
|
|
40
|
+
render(
|
|
41
|
+
<FieldRow label="Name" description="Your full name" error="Required">
|
|
42
|
+
<Input />
|
|
43
|
+
</FieldRow>,
|
|
44
|
+
);
|
|
45
|
+
const input = screen.getByRole("textbox");
|
|
46
|
+
const describedBy = input.getAttribute("aria-describedby");
|
|
47
|
+
expect(describedBy).toBeTruthy();
|
|
48
|
+
const ids = describedBy!.split(" ");
|
|
49
|
+
expect(ids).toHaveLength(2);
|
|
50
|
+
for (const id of ids) {
|
|
51
|
+
expect(document.getElementById(id)).not.toBeNull();
|
|
52
|
+
}
|
|
53
|
+
expect(screen.getByText("Your full name").id).toBe(ids[0]);
|
|
54
|
+
const error = screen.getByRole("alert");
|
|
55
|
+
expect(error).toHaveTextContent("Required");
|
|
56
|
+
expect(error.id).toBe(ids[1]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("sets aria-invalid only when an error is present", () => {
|
|
60
|
+
const { rerender } = render(
|
|
61
|
+
<FieldRow label="Name">
|
|
62
|
+
<Input />
|
|
63
|
+
</FieldRow>,
|
|
64
|
+
);
|
|
65
|
+
expect(screen.getByRole("textbox")).toHaveAttribute("aria-invalid", "false");
|
|
66
|
+
|
|
67
|
+
rerender(
|
|
68
|
+
<FieldRow label="Name" error="Required">
|
|
69
|
+
<Input />
|
|
70
|
+
</FieldRow>,
|
|
71
|
+
);
|
|
72
|
+
expect(screen.getByRole("textbox")).toHaveAttribute("aria-invalid", "true");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("omits aria-describedby entirely when there is no description and no error", () => {
|
|
76
|
+
render(
|
|
77
|
+
<FieldRow label="Name">
|
|
78
|
+
<Input />
|
|
79
|
+
</FieldRow>,
|
|
80
|
+
);
|
|
81
|
+
expect(screen.getByRole("textbox")).not.toHaveAttribute("aria-describedby");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Regression lock: Radix `Slot` merges CHILD props OVER slot props, so a
|
|
85
|
+
// control carrying its own `id` used to silently win — `<Label htmlFor>`
|
|
86
|
+
// then pointed at an element that did not exist and clicking the label
|
|
87
|
+
// focused nothing. FieldRow now composes with the child's own id.
|
|
88
|
+
it("keeps the label associated when the control supplies its own id", async () => {
|
|
89
|
+
render(
|
|
90
|
+
<FieldRow label="Email" description="Work address">
|
|
91
|
+
<Input id="consumer-owned-id" />
|
|
92
|
+
</FieldRow>,
|
|
93
|
+
);
|
|
94
|
+
const input = screen.getByRole("textbox");
|
|
95
|
+
expect(input.id).toBe("consumer-owned-id");
|
|
96
|
+
expect(screen.getByText("Email")).toHaveAttribute("for", "consumer-owned-id");
|
|
97
|
+
await userEvent.click(screen.getByText("Email"));
|
|
98
|
+
expect(input).toHaveFocus();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Same root cause: a child's own `aria-describedby` used to REPLACE the
|
|
102
|
+
// composed one, so the help and error text were never announced.
|
|
103
|
+
it("composes with (does not discard) a control's own aria-describedby", () => {
|
|
104
|
+
render(
|
|
105
|
+
<>
|
|
106
|
+
<span id="consumer-hint">Extra hint</span>
|
|
107
|
+
<FieldRow label="Name" description="Your full name" error="Required">
|
|
108
|
+
<Input aria-describedby="consumer-hint" />
|
|
109
|
+
</FieldRow>
|
|
110
|
+
</>,
|
|
111
|
+
);
|
|
112
|
+
const [hintId, descriptionId, errorId, ...rest] = screen
|
|
113
|
+
.getByRole("textbox")
|
|
114
|
+
.getAttribute("aria-describedby")!
|
|
115
|
+
.split(" ");
|
|
116
|
+
expect(rest).toHaveLength(0);
|
|
117
|
+
expect(hintId).toBe("consumer-hint");
|
|
118
|
+
for (const id of [hintId, descriptionId, errorId]) {
|
|
119
|
+
expect(document.getElementById(id!)).not.toBeNull();
|
|
120
|
+
}
|
|
121
|
+
expect(document.getElementById(descriptionId!)).toHaveTextContent("Your full name");
|
|
122
|
+
expect(document.getElementById(errorId!)).toHaveTextContent("Required");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// #354 — a field rendered through FieldRow must produce ACCESSIBLE MARKUP
|
|
126
|
+
// EQUIVALENT to the same field rendered through the existing react-hook-form
|
|
127
|
+
// path (Form/FormField/FormItem/FormLabel/FormControl/FormDescription/
|
|
128
|
+
// FormMessage), proven by a real assertion — not just claimed.
|
|
129
|
+
it("produces markup equivalent to the RHF-bound Form family for the same field shape", async () => {
|
|
130
|
+
function RhfField() {
|
|
131
|
+
const form = useForm({ defaultValues: { name: "" }, mode: "onChange" });
|
|
132
|
+
// Force a real validation error into RHF field state (not merely a
|
|
133
|
+
// static string prop), so FormControl/FormMessage compute their
|
|
134
|
+
// aria-invalid/aria-describedby/error text from the SAME code path a
|
|
135
|
+
// real consumer hits.
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
void form.trigger("name");
|
|
138
|
+
}, [form]);
|
|
139
|
+
return (
|
|
140
|
+
<Form {...form}>
|
|
141
|
+
<FormField
|
|
142
|
+
control={form.control}
|
|
143
|
+
name="name"
|
|
144
|
+
rules={{ required: "Required" }}
|
|
145
|
+
render={({ field }) => (
|
|
146
|
+
<FormItem>
|
|
147
|
+
<FormLabel>Name</FormLabel>
|
|
148
|
+
<FormControl>
|
|
149
|
+
<Input {...field} />
|
|
150
|
+
</FormControl>
|
|
151
|
+
<FormDescription>Your full name</FormDescription>
|
|
152
|
+
<FormMessage />
|
|
153
|
+
</FormItem>
|
|
154
|
+
)}
|
|
155
|
+
/>
|
|
156
|
+
</Form>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const { unmount } = render(<RhfField />);
|
|
161
|
+
const rhfInput = await screen.findByRole("textbox");
|
|
162
|
+
await waitFor(() => expect(rhfInput).toHaveAttribute("aria-invalid", "true"));
|
|
163
|
+
const rhfLabel = screen.getByText("Name");
|
|
164
|
+
const rhfDescribedBy = rhfInput.getAttribute("aria-describedby")!.split(" ");
|
|
165
|
+
expect(rhfLabel.tagName).toBe("LABEL");
|
|
166
|
+
expect(rhfLabel).toHaveAttribute("for", rhfInput.id);
|
|
167
|
+
expect(rhfDescribedBy).toHaveLength(2);
|
|
168
|
+
for (const id of rhfDescribedBy) expect(document.getElementById(id)).not.toBeNull();
|
|
169
|
+
unmount();
|
|
170
|
+
|
|
171
|
+
render(
|
|
172
|
+
<FieldRow label="Name" description="Your full name" error="Required">
|
|
173
|
+
<Input />
|
|
174
|
+
</FieldRow>,
|
|
175
|
+
);
|
|
176
|
+
const frInput = screen.getByRole("textbox");
|
|
177
|
+
const frLabel = screen.getByText("Name");
|
|
178
|
+
const frDescribedBy = frInput.getAttribute("aria-describedby")!.split(" ");
|
|
179
|
+
|
|
180
|
+
// Equivalent SHAPE of accessible markup (same relationship types) — not
|
|
181
|
+
// identical id strings, since each side mints its own useId().
|
|
182
|
+
expect(frLabel.tagName).toBe(rhfLabel.tagName);
|
|
183
|
+
expect(frLabel).toHaveAttribute("for", frInput.id);
|
|
184
|
+
expect(frDescribedBy).toHaveLength(rhfDescribedBy.length);
|
|
185
|
+
for (const id of frDescribedBy) expect(document.getElementById(id)).not.toBeNull();
|
|
186
|
+
expect(frInput).toHaveAttribute("aria-invalid", "true");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cloneElement,
|
|
3
|
+
forwardRef,
|
|
4
|
+
useId,
|
|
5
|
+
type HTMLAttributes,
|
|
6
|
+
type ReactElement,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
10
|
+
import { cn } from "../../lib/cn";
|
|
11
|
+
import { Label } from "../label";
|
|
12
|
+
|
|
13
|
+
export interface FieldRowProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
14
|
+
/** Field label — rendered as a real `<label>`, click-to-focus associated with the control. */
|
|
15
|
+
label: ReactNode;
|
|
16
|
+
/** Optional help/description text, always visible (not gated by an error). */
|
|
17
|
+
description?: ReactNode;
|
|
18
|
+
/**
|
|
19
|
+
* Validation error text. When present: sets `aria-invalid` on the control,
|
|
20
|
+
* is appended to `aria-describedby`, and is announced via `role="alert"`.
|
|
21
|
+
*/
|
|
22
|
+
error?: ReactNode;
|
|
23
|
+
/**
|
|
24
|
+
* The single field control (`Input`, `Textarea`, `Select`, …). Receives
|
|
25
|
+
* `id`/`aria-describedby`/`aria-invalid` via a Radix `Slot` — must be a
|
|
26
|
+
* single element that forwards those props to a real form control.
|
|
27
|
+
*/
|
|
28
|
+
children: ReactElement;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Label/description/error/`aria-describedby` wiring for a field OUTSIDE a
|
|
33
|
+
* `react-hook-form` tree — the same anatomy as `FormItem`/`FormLabel`/
|
|
34
|
+
* `FormControl`/`FormDescription`/`FormMessage` (`../form`), but driven by
|
|
35
|
+
* plain props (`label`/`description`/`error`) instead of RHF field state, so
|
|
36
|
+
* a single `useState`-controlled field doesn't need a full `<Form>` +
|
|
37
|
+
* `<FormField>` + `Controller` just to get correct label/error association.
|
|
38
|
+
*
|
|
39
|
+
* Reach for `Form`/`FormField`/… instead when the field already lives inside
|
|
40
|
+
* a `react-hook-form` `<FormProvider>` — this component does not replace that
|
|
41
|
+
* family, it covers the gap outside it (#354).
|
|
42
|
+
*/
|
|
43
|
+
export const FieldRow = forwardRef<HTMLDivElement, FieldRowProps>(function FieldRow(
|
|
44
|
+
{ label, description, error, children, className, ...props },
|
|
45
|
+
ref,
|
|
46
|
+
) {
|
|
47
|
+
const id = useId();
|
|
48
|
+
const descriptionId = `${id}-description`;
|
|
49
|
+
const errorId = `${id}-error`;
|
|
50
|
+
|
|
51
|
+
// Radix `Slot` merges CHILD props OVER slot props, so a control that carries
|
|
52
|
+
// its own `id` / `aria-describedby` (a perfectly ordinary `<Input id="email">`)
|
|
53
|
+
// silently wins — the `<Label htmlFor>` would then point at nothing and the
|
|
54
|
+
// description/error would never be announced, defeating the one thing this
|
|
55
|
+
// component exists to guarantee. So compose with the child's own values
|
|
56
|
+
// instead of assuming they are absent, and clone the child with the composed
|
|
57
|
+
// result so "child wins" resolves to the composed value either way.
|
|
58
|
+
const control = children as ReactElement<{
|
|
59
|
+
id?: string;
|
|
60
|
+
"aria-describedby"?: string;
|
|
61
|
+
}>;
|
|
62
|
+
const childProps = control.props;
|
|
63
|
+
const controlId = childProps.id ?? `${id}-control`;
|
|
64
|
+
|
|
65
|
+
const describedBy =
|
|
66
|
+
[
|
|
67
|
+
childProps["aria-describedby"] ?? null,
|
|
68
|
+
description ? descriptionId : null,
|
|
69
|
+
error ? errorId : null,
|
|
70
|
+
]
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.join(" ") || undefined;
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<div ref={ref} data-slot="field-row" className={cn("space-y-2", className)} {...props}>
|
|
76
|
+
<Label
|
|
77
|
+
htmlFor={controlId}
|
|
78
|
+
data-slot="field-row-label"
|
|
79
|
+
className={cn(
|
|
80
|
+
"transition-colors duration-fast ease-standard",
|
|
81
|
+
error && "text-destructive-text",
|
|
82
|
+
)}
|
|
83
|
+
>
|
|
84
|
+
{label}
|
|
85
|
+
</Label>
|
|
86
|
+
<Slot
|
|
87
|
+
id={controlId}
|
|
88
|
+
aria-describedby={describedBy}
|
|
89
|
+
aria-invalid={!!error}
|
|
90
|
+
data-slot="field-row-control"
|
|
91
|
+
>
|
|
92
|
+
{cloneElement(control, { id: controlId, "aria-describedby": describedBy })}
|
|
93
|
+
</Slot>
|
|
94
|
+
{description ? (
|
|
95
|
+
<p
|
|
96
|
+
id={descriptionId}
|
|
97
|
+
data-slot="field-row-description"
|
|
98
|
+
className="text-body text-muted-foreground"
|
|
99
|
+
>
|
|
100
|
+
{description}
|
|
101
|
+
</p>
|
|
102
|
+
) : null}
|
|
103
|
+
{error ? (
|
|
104
|
+
<p
|
|
105
|
+
id={errorId}
|
|
106
|
+
role="alert"
|
|
107
|
+
data-slot="field-row-error"
|
|
108
|
+
className="text-body font-medium text-destructive-text"
|
|
109
|
+
>
|
|
110
|
+
{error}
|
|
111
|
+
</p>
|
|
112
|
+
) : null}
|
|
113
|
+
</div>
|
|
114
|
+
);
|
|
115
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { FieldRow, type FieldRowProps } from "./field-row";
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import {
|
|
4
|
+
FileUpload,
|
|
5
|
+
FileUploadDropzone,
|
|
6
|
+
FileUploadList,
|
|
7
|
+
FileUploadItem,
|
|
8
|
+
type UploadFile,
|
|
9
|
+
} from "./file-upload";
|
|
10
|
+
|
|
11
|
+
const meta = {
|
|
12
|
+
title: "Forms/FileUpload",
|
|
13
|
+
component: FileUpload,
|
|
14
|
+
tags: ["autodocs"],
|
|
15
|
+
argTypes: {
|
|
16
|
+
files: {
|
|
17
|
+
description: "Controlled file list (`UploadFile[]`).",
|
|
18
|
+
control: false,
|
|
19
|
+
table: { category: "State" },
|
|
20
|
+
},
|
|
21
|
+
onFilesChange: {
|
|
22
|
+
description: "Called when files are added or removed.",
|
|
23
|
+
control: false,
|
|
24
|
+
table: { category: "Behavior" },
|
|
25
|
+
},
|
|
26
|
+
accept: {
|
|
27
|
+
description: "Accepted MIME types / extensions forwarded to the hidden `<input>`.",
|
|
28
|
+
control: "text",
|
|
29
|
+
table: { category: "Behavior" },
|
|
30
|
+
},
|
|
31
|
+
multiple: {
|
|
32
|
+
description: "Allow selecting multiple files at once.",
|
|
33
|
+
control: "boolean",
|
|
34
|
+
table: { category: "Behavior" },
|
|
35
|
+
},
|
|
36
|
+
maxSize: {
|
|
37
|
+
description: "Maximum individual file size in bytes.",
|
|
38
|
+
control: "number",
|
|
39
|
+
table: { category: "Behavior" },
|
|
40
|
+
},
|
|
41
|
+
maxFiles: {
|
|
42
|
+
description: "Maximum number of files in the list at once.",
|
|
43
|
+
control: "number",
|
|
44
|
+
table: { category: "Behavior" },
|
|
45
|
+
},
|
|
46
|
+
disabled: {
|
|
47
|
+
description: "Disable all interactions.",
|
|
48
|
+
control: "boolean",
|
|
49
|
+
table: { category: "State" },
|
|
50
|
+
},
|
|
51
|
+
children: {
|
|
52
|
+
description: "Compose with `FileUploadDropzone`, `FileUploadList`, and `FileUploadItem`.",
|
|
53
|
+
control: false,
|
|
54
|
+
table: { category: "Content" },
|
|
55
|
+
},
|
|
56
|
+
className: {
|
|
57
|
+
description: "Additional CSS classes applied to the root element.",
|
|
58
|
+
control: "text",
|
|
59
|
+
table: { category: "Appearance" },
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
} satisfies Meta<typeof FileUpload>;
|
|
63
|
+
export default meta;
|
|
64
|
+
type Story = StoryObj<typeof meta>;
|
|
65
|
+
|
|
66
|
+
// Seed files for visual stories
|
|
67
|
+
function makeFakeFile(name: string, size: number): File {
|
|
68
|
+
const file = new File(["x"], name, { type: "text/plain", lastModified: Date.now() });
|
|
69
|
+
// Override the byte size so the stories display realistic file sizes.
|
|
70
|
+
Object.defineProperty(file, "size", { value: size });
|
|
71
|
+
return file;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const seedFiles: UploadFile[] = [
|
|
75
|
+
{ id: "1", file: Object.assign(makeFakeFile("report.pdf", 204800), {}), status: "success" },
|
|
76
|
+
{
|
|
77
|
+
id: "2",
|
|
78
|
+
file: Object.assign(makeFakeFile("data.csv", 51200), {}),
|
|
79
|
+
status: "uploading",
|
|
80
|
+
progress: 60,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: "3",
|
|
84
|
+
file: Object.assign(makeFakeFile("broken.txt", 1024), {}),
|
|
85
|
+
status: "error",
|
|
86
|
+
errorMessage: "File exceeds size limit.",
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
export const Default: Story = {
|
|
91
|
+
render: () => (
|
|
92
|
+
<div className="max-w-md">
|
|
93
|
+
<FileUpload multiple accept="image/*,.pdf">
|
|
94
|
+
<FileUploadDropzone />
|
|
95
|
+
<FileUploadList>
|
|
96
|
+
{seedFiles.map((f) => (
|
|
97
|
+
<FileUploadItem
|
|
98
|
+
key={f.id}
|
|
99
|
+
uploadFile={f}
|
|
100
|
+
status={f.status}
|
|
101
|
+
progress={f.progress}
|
|
102
|
+
errorMessage={f.errorMessage}
|
|
103
|
+
/>
|
|
104
|
+
))}
|
|
105
|
+
</FileUploadList>
|
|
106
|
+
</FileUpload>
|
|
107
|
+
</div>
|
|
108
|
+
),
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
export const Uncontrolled: Story = {
|
|
112
|
+
render: () => (
|
|
113
|
+
<div className="max-w-md">
|
|
114
|
+
<FileUpload multiple accept="image/*,.pdf,.docx" maxSize={5 * 1024 * 1024} maxFiles={5}>
|
|
115
|
+
<FileUploadDropzone />
|
|
116
|
+
<FileUploadList />
|
|
117
|
+
</FileUpload>
|
|
118
|
+
</div>
|
|
119
|
+
),
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export const Controlled: Story = {
|
|
123
|
+
render: () => {
|
|
124
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
125
|
+
const [files, setFiles] = useState<UploadFile[]>([]);
|
|
126
|
+
return (
|
|
127
|
+
<div className="flex flex-col gap-4 max-w-md">
|
|
128
|
+
<FileUpload files={files} onFilesChange={setFiles} multiple>
|
|
129
|
+
<FileUploadDropzone />
|
|
130
|
+
<FileUploadList>
|
|
131
|
+
{files.map((f) => (
|
|
132
|
+
<FileUploadItem key={f.id} uploadFile={f} />
|
|
133
|
+
))}
|
|
134
|
+
</FileUploadList>
|
|
135
|
+
</FileUpload>
|
|
136
|
+
<p className="text-body text-muted-foreground">
|
|
137
|
+
{files.length} file{files.length !== 1 ? "s" : ""} selected
|
|
138
|
+
</p>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const WithProgress: Story = {
|
|
145
|
+
render: () => (
|
|
146
|
+
<div className="max-w-md">
|
|
147
|
+
<FileUpload>
|
|
148
|
+
<FileUploadDropzone />
|
|
149
|
+
<FileUploadList>
|
|
150
|
+
<FileUploadItem uploadFile={seedFiles[1]} status="uploading" progress={60} />
|
|
151
|
+
<FileUploadItem uploadFile={seedFiles[0]} status="success" />
|
|
152
|
+
</FileUploadList>
|
|
153
|
+
</FileUpload>
|
|
154
|
+
</div>
|
|
155
|
+
),
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
export const WithError: Story = {
|
|
159
|
+
render: () => (
|
|
160
|
+
<div className="max-w-md">
|
|
161
|
+
<FileUpload>
|
|
162
|
+
<FileUploadDropzone />
|
|
163
|
+
<FileUploadList>
|
|
164
|
+
<FileUploadItem
|
|
165
|
+
uploadFile={seedFiles[2]}
|
|
166
|
+
status="error"
|
|
167
|
+
errorMessage="Server rejected the file."
|
|
168
|
+
/>
|
|
169
|
+
</FileUploadList>
|
|
170
|
+
</FileUpload>
|
|
171
|
+
</div>
|
|
172
|
+
),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const Disabled: Story = {
|
|
176
|
+
render: () => (
|
|
177
|
+
<div className="max-w-md">
|
|
178
|
+
<FileUpload disabled>
|
|
179
|
+
<FileUploadDropzone />
|
|
180
|
+
</FileUpload>
|
|
181
|
+
</div>
|
|
182
|
+
),
|
|
183
|
+
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { render, screen, fireEvent } from "@testing-library/react";
|
|
3
|
+
import {
|
|
4
|
+
FileUpload,
|
|
5
|
+
FileUploadDropzone,
|
|
6
|
+
FileUploadList,
|
|
7
|
+
FileUploadItem,
|
|
8
|
+
type UploadFile,
|
|
9
|
+
} from "./file-upload";
|
|
10
|
+
|
|
11
|
+
function makeUploadFile(name = "test.txt", size = 1024): UploadFile {
|
|
12
|
+
const file = new File(["x"], name, { type: "text/plain" });
|
|
13
|
+
Object.defineProperty(file, "size", { value: size });
|
|
14
|
+
return { id: `id-${name}`, file };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("FileUpload", () => {
|
|
18
|
+
it("renders the dropzone with a label pointing to the hidden input", () => {
|
|
19
|
+
render(
|
|
20
|
+
<FileUpload>
|
|
21
|
+
<FileUploadDropzone />
|
|
22
|
+
</FileUpload>,
|
|
23
|
+
);
|
|
24
|
+
// The label wraps the input — label's htmlFor matches input's id
|
|
25
|
+
const label = document.querySelector("label[data-slot='file-upload-dropzone']");
|
|
26
|
+
expect(label).not.toBeNull();
|
|
27
|
+
const inputId = label!.getAttribute("for");
|
|
28
|
+
expect(inputId).toBeTruthy();
|
|
29
|
+
const input = document.getElementById(inputId!);
|
|
30
|
+
expect(input).not.toBeNull();
|
|
31
|
+
expect(input!.tagName.toLowerCase()).toBe("input");
|
|
32
|
+
expect(input).toHaveAttribute("type", "file");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("hidden file input has sr-only class (visually hidden but real)", () => {
|
|
36
|
+
render(
|
|
37
|
+
<FileUpload>
|
|
38
|
+
<FileUploadDropzone />
|
|
39
|
+
</FileUpload>,
|
|
40
|
+
);
|
|
41
|
+
const input = document.querySelector("input[type='file']");
|
|
42
|
+
expect(input).not.toBeNull();
|
|
43
|
+
expect(input).toHaveClass("sr-only");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("calls onFilesChange when a file is added (controlled)", () => {
|
|
47
|
+
const onFilesChange = vi.fn();
|
|
48
|
+
render(
|
|
49
|
+
<FileUpload files={[]} onFilesChange={onFilesChange}>
|
|
50
|
+
<FileUploadDropzone />
|
|
51
|
+
</FileUpload>,
|
|
52
|
+
);
|
|
53
|
+
const input = document.querySelector("input[type='file']") as HTMLInputElement;
|
|
54
|
+
const file = new File(["hello"], "hello.txt", { type: "text/plain" });
|
|
55
|
+
fireEvent.change(input, { target: { files: [file] } });
|
|
56
|
+
expect(onFilesChange).toHaveBeenCalledOnce();
|
|
57
|
+
expect(onFilesChange.mock.calls[0]![0]).toHaveLength(1);
|
|
58
|
+
expect(onFilesChange.mock.calls[0]![0][0].file.name).toBe("hello.txt");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("respects maxFiles — does not exceed the limit", () => {
|
|
62
|
+
const onFilesChange = vi.fn();
|
|
63
|
+
render(
|
|
64
|
+
<FileUpload files={[]} onFilesChange={onFilesChange} maxFiles={1}>
|
|
65
|
+
<FileUploadDropzone />
|
|
66
|
+
</FileUpload>,
|
|
67
|
+
);
|
|
68
|
+
const input = document.querySelector("input[type='file']") as HTMLInputElement;
|
|
69
|
+
const files = [
|
|
70
|
+
new File(["a"], "a.txt", { type: "text/plain" }),
|
|
71
|
+
new File(["b"], "b.txt", { type: "text/plain" }),
|
|
72
|
+
];
|
|
73
|
+
fireEvent.change(input, { target: { files } });
|
|
74
|
+
expect(onFilesChange.mock.calls[0]![0]).toHaveLength(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("respects maxSize — skips files over the limit", () => {
|
|
78
|
+
const onFilesChange = vi.fn();
|
|
79
|
+
render(
|
|
80
|
+
<FileUpload files={[]} onFilesChange={onFilesChange} maxSize={10}>
|
|
81
|
+
<FileUploadDropzone />
|
|
82
|
+
</FileUpload>,
|
|
83
|
+
);
|
|
84
|
+
const input = document.querySelector("input[type='file']") as HTMLInputElement;
|
|
85
|
+
// File content "x".repeat(100) is 100 bytes > 10
|
|
86
|
+
const big = new File(["x".repeat(100)], "big.txt", { type: "text/plain" });
|
|
87
|
+
fireEvent.change(input, { target: { files: [big] } });
|
|
88
|
+
expect(onFilesChange.mock.calls[0]![0]).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("FileUploadList renders role=status and aria-live=polite", () => {
|
|
92
|
+
const f = makeUploadFile();
|
|
93
|
+
render(
|
|
94
|
+
<FileUpload files={[f]} onFilesChange={() => {}}>
|
|
95
|
+
<FileUploadList>
|
|
96
|
+
<FileUploadItem uploadFile={f} />
|
|
97
|
+
</FileUploadList>
|
|
98
|
+
</FileUpload>,
|
|
99
|
+
);
|
|
100
|
+
const list = screen.getByRole("status");
|
|
101
|
+
expect(list).toHaveAttribute("aria-live", "polite");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("FileUploadList returns null when no files and no children", () => {
|
|
105
|
+
const { container } = render(
|
|
106
|
+
<FileUpload files={[]} onFilesChange={() => {}}>
|
|
107
|
+
<FileUploadList />
|
|
108
|
+
</FileUpload>,
|
|
109
|
+
);
|
|
110
|
+
expect(container.querySelector("[data-slot='file-upload-list']")).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("FileUploadItem shows progress bar when status=uploading", () => {
|
|
114
|
+
const f = makeUploadFile();
|
|
115
|
+
render(
|
|
116
|
+
<FileUpload files={[f]} onFilesChange={() => {}}>
|
|
117
|
+
<FileUploadList>
|
|
118
|
+
<FileUploadItem uploadFile={f} status="uploading" progress={42} />
|
|
119
|
+
</FileUploadList>
|
|
120
|
+
</FileUpload>,
|
|
121
|
+
);
|
|
122
|
+
// Progress bar is present (Radix renders a progressbar role)
|
|
123
|
+
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("FileUploadItem shows role=alert for error messages", () => {
|
|
127
|
+
const f = makeUploadFile();
|
|
128
|
+
render(
|
|
129
|
+
<FileUpload files={[f]} onFilesChange={() => {}}>
|
|
130
|
+
<FileUploadList>
|
|
131
|
+
<FileUploadItem uploadFile={f} status="error" errorMessage="Upload failed." />
|
|
132
|
+
</FileUploadList>
|
|
133
|
+
</FileUpload>,
|
|
134
|
+
);
|
|
135
|
+
const alert = screen.getByRole("alert");
|
|
136
|
+
expect(alert).toHaveTextContent("Upload failed.");
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("remove button calls removeFile via context", () => {
|
|
140
|
+
const onFilesChange = vi.fn();
|
|
141
|
+
const f = makeUploadFile("removeme.txt");
|
|
142
|
+
render(
|
|
143
|
+
<FileUpload files={[f]} onFilesChange={onFilesChange}>
|
|
144
|
+
<FileUploadList>
|
|
145
|
+
<FileUploadItem uploadFile={f} />
|
|
146
|
+
</FileUploadList>
|
|
147
|
+
</FileUpload>,
|
|
148
|
+
);
|
|
149
|
+
const removeBtn = screen.getByRole("button", { name: /remove removeme/i });
|
|
150
|
+
fireEvent.click(removeBtn);
|
|
151
|
+
expect(onFilesChange).toHaveBeenCalledWith([]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("disabled prop disables the file input and remove buttons", () => {
|
|
155
|
+
const f = makeUploadFile();
|
|
156
|
+
render(
|
|
157
|
+
<FileUpload files={[f]} onFilesChange={() => {}} disabled>
|
|
158
|
+
<FileUploadDropzone />
|
|
159
|
+
<FileUploadList>
|
|
160
|
+
<FileUploadItem uploadFile={f} />
|
|
161
|
+
</FileUploadList>
|
|
162
|
+
</FileUpload>,
|
|
163
|
+
);
|
|
164
|
+
const input = document.querySelector("input[type='file']") as HTMLInputElement;
|
|
165
|
+
expect(input).toBeDisabled();
|
|
166
|
+
const removeBtn = screen.getByRole("button", { name: /remove/i });
|
|
167
|
+
expect(removeBtn).toBeDisabled();
|
|
168
|
+
});
|
|
169
|
+
});
|