@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,170 @@
|
|
|
1
|
+
import {
|
|
2
|
+
forwardRef,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
type ComponentPropsWithoutRef,
|
|
7
|
+
type ElementRef,
|
|
8
|
+
} from "react";
|
|
9
|
+
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
|
10
|
+
import { useReducedMotion } from "@elabs-ai/components-tokens";
|
|
11
|
+
import { cn } from "../../lib/cn";
|
|
12
|
+
import { mergeRefs } from "../../lib/merge-refs";
|
|
13
|
+
|
|
14
|
+
export const Tabs = TabsPrimitive.Root;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Bring `trigger` inside the visible bounds of ITS OWN tab strip.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately NOT `Element.scrollIntoView()`: that walks every scrollable
|
|
20
|
+
* ancestor up to the document, so a Tabs sitting below the fold yanks the
|
|
21
|
+
* whole PAGE (measured: `window.scrollY` 0 → 900 on a non-overflowing strip
|
|
22
|
+
* under a 3000px spacer). Scrolling the strip itself is the only movement
|
|
23
|
+
* this feature is entitled to make. It is also why nothing here throws under
|
|
24
|
+
* jsdom, where `scrollIntoView` does not exist: the overflow gate below
|
|
25
|
+
* returns first (`scrollWidth === clientWidth === 0`) and the scroll call is
|
|
26
|
+
* feature-detected.
|
|
27
|
+
*/
|
|
28
|
+
function scrollTriggerIntoStrip(trigger: HTMLElement, behavior: ScrollBehavior) {
|
|
29
|
+
const strip = trigger.closest<HTMLElement>('[role="tablist"]');
|
|
30
|
+
if (!strip) return;
|
|
31
|
+
// A strip that fits has nothing to scroll — keep it byte-for-byte inert.
|
|
32
|
+
if (strip.scrollWidth <= strip.clientWidth) return;
|
|
33
|
+
|
|
34
|
+
// Keep the strip's own padding as a gutter, so the tab lands inset (with its
|
|
35
|
+
// focus ring / active shadow visible) rather than flush against the edge.
|
|
36
|
+
const { paddingLeft, paddingRight } = getComputedStyle(strip);
|
|
37
|
+
const triggerRect = trigger.getBoundingClientRect();
|
|
38
|
+
const stripRect = strip.getBoundingClientRect();
|
|
39
|
+
const visibleLeft = stripRect.left + (parseFloat(paddingLeft) || 0);
|
|
40
|
+
const visibleRight = stripRect.right - (parseFloat(paddingRight) || 0);
|
|
41
|
+
const delta =
|
|
42
|
+
triggerRect.left < visibleLeft
|
|
43
|
+
? triggerRect.left - visibleLeft
|
|
44
|
+
: triggerRect.right > visibleRight
|
|
45
|
+
? triggerRect.right - visibleRight
|
|
46
|
+
: 0;
|
|
47
|
+
// Nothing to do: the tab is already fully visible — typically because the
|
|
48
|
+
// browser's own focus-scroll got there first on a keyboard/pointer
|
|
49
|
+
// activation. That check is what keeps this from fighting the browser.
|
|
50
|
+
if (delta === 0) return;
|
|
51
|
+
|
|
52
|
+
// An ABSOLUTE target, not `scrollBy(delta)`: a relative scroll issued while
|
|
53
|
+
// an earlier smooth scroll is still in flight accumulates against a moving
|
|
54
|
+
// offset and lands short. `scrollLeft + delta` is read in the same instant
|
|
55
|
+
// as the rects, so it is exact whatever the animation is doing.
|
|
56
|
+
const left = strip.scrollLeft + delta;
|
|
57
|
+
if (typeof strip.scrollTo === "function") strip.scrollTo({ left, behavior });
|
|
58
|
+
else strip.scrollLeft = left;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* `overflow-x-auto` + `max-w-full` turn an overflowing tab strip into a
|
|
63
|
+
* scroll container instead of silently clipping it — a bare `inline-flex`
|
|
64
|
+
* has no bound on this element's own width, so without `max-w-full` it would
|
|
65
|
+
* just grow past its parent instead of scrolling. `justify-center-safe`
|
|
66
|
+
* (not `justify-center`) is required too: plain `justify-center` strands the
|
|
67
|
+
* first tab off the left edge once the set overflows; `safe` falls back to
|
|
68
|
+
* start-alignment on overflow while staying visually identical when the
|
|
69
|
+
* strip fits (see #344).
|
|
70
|
+
*/
|
|
71
|
+
export const TabsList = forwardRef<
|
|
72
|
+
ElementRef<typeof TabsPrimitive.List>,
|
|
73
|
+
ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
|
74
|
+
>(function TabsList({ className, ...props }, ref) {
|
|
75
|
+
return (
|
|
76
|
+
<TabsPrimitive.List
|
|
77
|
+
ref={ref}
|
|
78
|
+
className={cn(
|
|
79
|
+
"inline-flex h-9 max-w-full items-center justify-center-safe overflow-x-auto rounded-lg bg-muted p-1 text-muted-foreground",
|
|
80
|
+
className,
|
|
81
|
+
)}
|
|
82
|
+
{...props}
|
|
83
|
+
/>
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const TabsTrigger = forwardRef<
|
|
88
|
+
ElementRef<typeof TabsPrimitive.Trigger>,
|
|
89
|
+
ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
|
90
|
+
>(function TabsTrigger({ className, ...props }, ref) {
|
|
91
|
+
const innerRef = useRef<HTMLButtonElement>(null);
|
|
92
|
+
const mergedRef = useMemo(() => mergeRefs(ref, innerRef), [ref]);
|
|
93
|
+
// Honours the in-app tri-state motion preference on `ThemeProvider` as well
|
|
94
|
+
// as the OS setting, and degrades to OS-only outside a provider.
|
|
95
|
+
const reducedMotion = useReducedMotion();
|
|
96
|
+
|
|
97
|
+
// Keep the tab that BECOMES active inside the strip's visible bounds.
|
|
98
|
+
//
|
|
99
|
+
// Watching the attribute rather than reacting to a render is load-bearing on
|
|
100
|
+
// both counts:
|
|
101
|
+
// - Radix flips `data-state` on the trigger it renders INSIDE this wrapper,
|
|
102
|
+
// and only its own context consumer re-renders — this component does not.
|
|
103
|
+
// A render-driven effect therefore never observes an activation at all
|
|
104
|
+
// (measured: 8 effect runs for 8 triggers at mount, zero on any later
|
|
105
|
+
// activation), so the scroll only ever ran when the CONSUMER's own state
|
|
106
|
+
// changed.
|
|
107
|
+
// - A MutationObserver fires on a genuine inactive → active TRANSITION and
|
|
108
|
+
// never at mount, which is what stops a freshly-mounted Tabs from
|
|
109
|
+
// scrolling anything (#344 shipped a version that scrolled the page 900px
|
|
110
|
+
// on every mount).
|
|
111
|
+
useEffect(() => {
|
|
112
|
+
const node = innerRef.current;
|
|
113
|
+
if (!node) return;
|
|
114
|
+
|
|
115
|
+
const scrollIfActive = () => {
|
|
116
|
+
if (node.getAttribute("data-state") !== "active") return;
|
|
117
|
+
scrollTriggerIntoStrip(node, reducedMotion ? "auto" : "smooth");
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const observer = new MutationObserver(scrollIfActive);
|
|
121
|
+
observer.observe(node, { attributes: true, attributeFilter: ["data-state"] });
|
|
122
|
+
|
|
123
|
+
// A relayout AFTER the scroll invalidates the target it was computed from:
|
|
124
|
+
// a webfont swapping in on a cold load (measured — a mono-everything theme's
|
|
125
|
+
// wider mono face left the last tab 45px outside the strip), or the strip
|
|
126
|
+
// being resized. Re-measure whenever the tab or the strip changes size;
|
|
127
|
+
// the delta check makes every unaffected pass a no-op.
|
|
128
|
+
const resizeObserver =
|
|
129
|
+
typeof ResizeObserver === "function" ? new ResizeObserver(scrollIfActive) : undefined;
|
|
130
|
+
resizeObserver?.observe(node);
|
|
131
|
+
const strip = node.closest<HTMLElement>('[role="tablist"]');
|
|
132
|
+
if (strip) resizeObserver?.observe(strip);
|
|
133
|
+
|
|
134
|
+
return () => {
|
|
135
|
+
observer.disconnect();
|
|
136
|
+
resizeObserver?.disconnect();
|
|
137
|
+
};
|
|
138
|
+
}, [reducedMotion]);
|
|
139
|
+
|
|
140
|
+
return (
|
|
141
|
+
<TabsPrimitive.Trigger
|
|
142
|
+
ref={mergedRef}
|
|
143
|
+
className={cn(
|
|
144
|
+
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium transition-colors duration-fast",
|
|
145
|
+
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
146
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
147
|
+
"data-[state=active]:bg-surface-elevated data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
|
148
|
+
className,
|
|
149
|
+
)}
|
|
150
|
+
{...props}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
export const TabsContent = forwardRef<
|
|
156
|
+
ElementRef<typeof TabsPrimitive.Content>,
|
|
157
|
+
ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
|
158
|
+
>(function TabsContent({ className, ...props }, ref) {
|
|
159
|
+
return (
|
|
160
|
+
<TabsPrimitive.Content
|
|
161
|
+
ref={ref}
|
|
162
|
+
className={cn(
|
|
163
|
+
"mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
164
|
+
"data-[state=active]:animate-in data-[state=active]:fade-in-0 data-[state=active]:slide-in-from-bottom-1 data-[state=active]:[--tw-ease:var(--ease-entrance)]",
|
|
165
|
+
className,
|
|
166
|
+
)}
|
|
167
|
+
{...props}
|
|
168
|
+
/>
|
|
169
|
+
);
|
|
170
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { TagInput, type TagInputProps } from "./tag-input";
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect } from "storybook/test";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { TagInput } from "./tag-input";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Forms/TagInput",
|
|
8
|
+
component: TagInput,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
args: {
|
|
11
|
+
placeholder: "Add tag…",
|
|
12
|
+
},
|
|
13
|
+
argTypes: {
|
|
14
|
+
value: {
|
|
15
|
+
description: "Controlled tag list.",
|
|
16
|
+
control: false,
|
|
17
|
+
table: { category: "State" },
|
|
18
|
+
},
|
|
19
|
+
defaultValue: {
|
|
20
|
+
description: "Uncontrolled initial tag list.",
|
|
21
|
+
control: false,
|
|
22
|
+
table: { category: "State" },
|
|
23
|
+
},
|
|
24
|
+
max: {
|
|
25
|
+
description: "Maximum number of tags allowed.",
|
|
26
|
+
control: "number",
|
|
27
|
+
table: { category: "Behavior" },
|
|
28
|
+
},
|
|
29
|
+
allowDuplicates: {
|
|
30
|
+
description: "Allow adding the same tag more than once.",
|
|
31
|
+
control: "boolean",
|
|
32
|
+
table: { category: "Behavior" },
|
|
33
|
+
},
|
|
34
|
+
placeholder: {
|
|
35
|
+
description: "Placeholder text for the entry field.",
|
|
36
|
+
control: "text",
|
|
37
|
+
table: { category: "Content" },
|
|
38
|
+
},
|
|
39
|
+
disabled: {
|
|
40
|
+
description: "Disables the input.",
|
|
41
|
+
control: "boolean",
|
|
42
|
+
table: { category: "State" },
|
|
43
|
+
},
|
|
44
|
+
delimiter: {
|
|
45
|
+
description: "Characters that trigger tag creation in addition to Enter (default `[',']`).",
|
|
46
|
+
control: false,
|
|
47
|
+
table: { category: "Behavior" },
|
|
48
|
+
},
|
|
49
|
+
validate: {
|
|
50
|
+
description: "Function to validate a candidate tag before adding.",
|
|
51
|
+
control: false,
|
|
52
|
+
table: { category: "Behavior" },
|
|
53
|
+
},
|
|
54
|
+
onValueChange: {
|
|
55
|
+
description: "Called when the tag list changes.",
|
|
56
|
+
control: false,
|
|
57
|
+
table: { category: "Behavior" },
|
|
58
|
+
},
|
|
59
|
+
className: {
|
|
60
|
+
description: "Additional CSS classes applied to the root element.",
|
|
61
|
+
control: "text",
|
|
62
|
+
table: { category: "Appearance" },
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
} satisfies Meta<typeof TagInput>;
|
|
66
|
+
export default meta;
|
|
67
|
+
type Story = StoryObj<typeof meta>;
|
|
68
|
+
|
|
69
|
+
export const Default: Story = {
|
|
70
|
+
args: { defaultValue: ["react", "typescript"] },
|
|
71
|
+
// Types a new tag and presses Enter; confirms the badge appears.
|
|
72
|
+
play: async ({ canvas, userEvent }) => {
|
|
73
|
+
const input = canvas.getByRole("textbox");
|
|
74
|
+
await userEvent.click(input);
|
|
75
|
+
await userEvent.type(input, "storybook");
|
|
76
|
+
await userEvent.keyboard("{Enter}");
|
|
77
|
+
const badge = await canvas.findByText("storybook");
|
|
78
|
+
await expect(badge).toBeInTheDocument();
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const Controlled: Story = {
|
|
83
|
+
render: () => {
|
|
84
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
85
|
+
const [tags, setTags] = useState(["design", "system"]);
|
|
86
|
+
return (
|
|
87
|
+
<div className="flex flex-col gap-2 max-w-sm">
|
|
88
|
+
<TagInput value={tags} onValueChange={setTags} placeholder="Add tag…" />
|
|
89
|
+
<p className="text-body text-muted-foreground">Tags: {tags.join(", ") || "none"}</p>
|
|
90
|
+
</div>
|
|
91
|
+
);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
export const WithMax: Story = {
|
|
96
|
+
args: { defaultValue: ["one", "two"], max: 3, placeholder: "Max 3 tags…" },
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
export const WithValidation: Story = {
|
|
100
|
+
args: {
|
|
101
|
+
defaultValue: [],
|
|
102
|
+
placeholder: "Only lowercase letters…",
|
|
103
|
+
validate: (tag) => {
|
|
104
|
+
if (!/^[a-z]+$/.test(tag)) return "Only lowercase letters allowed.";
|
|
105
|
+
return true;
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
export const AllowDuplicates: Story = {
|
|
111
|
+
args: { defaultValue: ["foo"], allowDuplicates: true, placeholder: "Duplicates OK…" },
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const Disabled: Story = {
|
|
115
|
+
args: { defaultValue: ["locked"], disabled: true },
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
export const Empty: Story = {
|
|
119
|
+
args: { placeholder: "Type and press Enter or comma…" },
|
|
120
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { TagInput } from "./tag-input";
|
|
5
|
+
|
|
6
|
+
describe("TagInput", () => {
|
|
7
|
+
it("renders existing tags", () => {
|
|
8
|
+
render(<TagInput defaultValue={["react", "typescript"]} aria-label="Tags" />);
|
|
9
|
+
expect(screen.getByText("react")).toBeInTheDocument();
|
|
10
|
+
expect(screen.getByText("typescript")).toBeInTheDocument();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("adds a tag on Enter", async () => {
|
|
14
|
+
const onValueChange = vi.fn();
|
|
15
|
+
render(<TagInput aria-label="Tags" onValueChange={onValueChange} />);
|
|
16
|
+
const input = screen.getByRole("textbox", { name: "Tags" });
|
|
17
|
+
await userEvent.type(input, "newtag{Enter}");
|
|
18
|
+
expect(onValueChange).toHaveBeenCalledWith(["newtag"]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("adds a tag on comma delimiter", async () => {
|
|
22
|
+
const onValueChange = vi.fn();
|
|
23
|
+
render(<TagInput aria-label="Tags" onValueChange={onValueChange} />);
|
|
24
|
+
const input = screen.getByRole("textbox", { name: "Tags" });
|
|
25
|
+
await userEvent.type(input, "alpha,");
|
|
26
|
+
expect(onValueChange).toHaveBeenCalledWith(["alpha"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("removes a tag via remove button", async () => {
|
|
30
|
+
const onValueChange = vi.fn();
|
|
31
|
+
render(<TagInput defaultValue={["foo", "bar"]} onValueChange={onValueChange} />);
|
|
32
|
+
await userEvent.click(screen.getByRole("button", { name: "Remove foo" }));
|
|
33
|
+
expect(onValueChange).toHaveBeenCalledWith(["bar"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("removes last tag on Backspace when input is empty", async () => {
|
|
37
|
+
const onValueChange = vi.fn();
|
|
38
|
+
render(
|
|
39
|
+
<TagInput defaultValue={["one", "two"]} aria-label="Tags" onValueChange={onValueChange} />,
|
|
40
|
+
);
|
|
41
|
+
const input = screen.getByRole("textbox", { name: "Tags" });
|
|
42
|
+
await userEvent.click(input);
|
|
43
|
+
await userEvent.keyboard("{Backspace}");
|
|
44
|
+
expect(onValueChange).toHaveBeenCalledWith(["one"]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("does not add duplicate tags by default", async () => {
|
|
48
|
+
const onValueChange = vi.fn();
|
|
49
|
+
render(<TagInput defaultValue={["dup"]} aria-label="Tags" onValueChange={onValueChange} />);
|
|
50
|
+
const input = screen.getByRole("textbox", { name: "Tags" });
|
|
51
|
+
await userEvent.type(input, "dup{Enter}");
|
|
52
|
+
// error should appear
|
|
53
|
+
expect(screen.getByRole("alert")).toBeInTheDocument();
|
|
54
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("allows duplicates when allowDuplicates=true", async () => {
|
|
58
|
+
const onValueChange = vi.fn();
|
|
59
|
+
render(
|
|
60
|
+
<TagInput
|
|
61
|
+
defaultValue={["dup"]}
|
|
62
|
+
aria-label="Tags"
|
|
63
|
+
allowDuplicates
|
|
64
|
+
onValueChange={onValueChange}
|
|
65
|
+
/>,
|
|
66
|
+
);
|
|
67
|
+
const input = screen.getByRole("textbox", { name: "Tags" });
|
|
68
|
+
await userEvent.type(input, "dup{Enter}");
|
|
69
|
+
expect(onValueChange).toHaveBeenCalledWith(["dup", "dup"]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("shows validation error message when validate returns a string", async () => {
|
|
73
|
+
render(<TagInput aria-label="Tags" validate={(t) => (t.length < 3 ? "Too short." : true)} />);
|
|
74
|
+
const input = screen.getByRole("textbox", { name: "Tags" });
|
|
75
|
+
await userEvent.type(input, "ab{Enter}");
|
|
76
|
+
expect(screen.getByRole("alert")).toHaveTextContent("Too short.");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("enforces max tag count", async () => {
|
|
80
|
+
render(<TagInput defaultValue={["a", "b"]} max={2} aria-label="Tags" />);
|
|
81
|
+
// input should be disabled when at max
|
|
82
|
+
expect(screen.getByRole("textbox", { name: "Tags" })).toBeDisabled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("prevents Enter from submitting a wrapping form", async () => {
|
|
86
|
+
const onSubmit = vi.fn();
|
|
87
|
+
render(
|
|
88
|
+
<form onSubmit={onSubmit}>
|
|
89
|
+
<TagInput aria-label="Tags" />
|
|
90
|
+
<button type="submit">Submit</button>
|
|
91
|
+
</form>,
|
|
92
|
+
);
|
|
93
|
+
const input = screen.getByRole("textbox", { name: "Tags" });
|
|
94
|
+
await userEvent.type(input, "hello{Enter}");
|
|
95
|
+
expect(onSubmit).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// a2ui.exposed: yes
|
|
2
|
+
import {
|
|
3
|
+
forwardRef,
|
|
4
|
+
useCallback,
|
|
5
|
+
useMemo,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type HTMLAttributes,
|
|
9
|
+
type KeyboardEvent,
|
|
10
|
+
} from "react";
|
|
11
|
+
import { X } from "lucide-react";
|
|
12
|
+
import { cn } from "../../lib/cn";
|
|
13
|
+
import { Badge } from "../badge";
|
|
14
|
+
import { InputGroup } from "../input-group";
|
|
15
|
+
|
|
16
|
+
export interface TagInputProps extends Omit<
|
|
17
|
+
HTMLAttributes<HTMLDivElement>,
|
|
18
|
+
"defaultValue" | "onChange"
|
|
19
|
+
> {
|
|
20
|
+
/** Controlled tag list. */
|
|
21
|
+
value?: string[];
|
|
22
|
+
/** Uncontrolled initial tag list. */
|
|
23
|
+
defaultValue?: string[];
|
|
24
|
+
/** Called when the tag list changes. */
|
|
25
|
+
onValueChange?: (value: string[]) => void;
|
|
26
|
+
/** Maximum number of tags allowed. */
|
|
27
|
+
max?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Validate a candidate tag before it is added.
|
|
30
|
+
* Return `true` to accept, `false` to silently reject, or a string
|
|
31
|
+
* to reject and show as an inline error message.
|
|
32
|
+
*/
|
|
33
|
+
validate?: (tag: string) => boolean | string;
|
|
34
|
+
/**
|
|
35
|
+
* Characters (or strings) that trigger tag creation in addition to Enter.
|
|
36
|
+
* Defaults to `[","]`.
|
|
37
|
+
*/
|
|
38
|
+
delimiter?: string[];
|
|
39
|
+
/** Allow duplicate tags (default false). */
|
|
40
|
+
allowDuplicates?: boolean;
|
|
41
|
+
/** Placeholder text for the entry field. */
|
|
42
|
+
placeholder?: string;
|
|
43
|
+
/** Whether the input is disabled. */
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
/** id for the underlying text input (useful for external <label>). */
|
|
46
|
+
id?: string;
|
|
47
|
+
/** Name forwarded to the underlying input. */
|
|
48
|
+
name?: string;
|
|
49
|
+
/** aria-label for the underlying text input. */
|
|
50
|
+
"aria-label"?: string;
|
|
51
|
+
/** aria-labelledby for the underlying text input. */
|
|
52
|
+
"aria-labelledby"?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* TagInput — chip-style multi-value text entry.
|
|
57
|
+
*
|
|
58
|
+
* Controlled via `value`/`onValueChange`; uncontrolled via `defaultValue`.
|
|
59
|
+
* Enter + configurable delimiters (default `[","]`) add a tag.
|
|
60
|
+
* Backspace on an empty field removes the last tag.
|
|
61
|
+
* Inline validation errors render with `role="alert"`.
|
|
62
|
+
*/
|
|
63
|
+
export const TagInput = forwardRef<HTMLDivElement, TagInputProps>(function TagInput(
|
|
64
|
+
{
|
|
65
|
+
value: valueProp,
|
|
66
|
+
defaultValue,
|
|
67
|
+
onValueChange,
|
|
68
|
+
max,
|
|
69
|
+
validate,
|
|
70
|
+
delimiter = [","],
|
|
71
|
+
allowDuplicates = false,
|
|
72
|
+
placeholder,
|
|
73
|
+
disabled,
|
|
74
|
+
id,
|
|
75
|
+
name,
|
|
76
|
+
"aria-label": ariaLabel,
|
|
77
|
+
"aria-labelledby": ariaLabelledby,
|
|
78
|
+
className,
|
|
79
|
+
...props
|
|
80
|
+
},
|
|
81
|
+
ref,
|
|
82
|
+
) {
|
|
83
|
+
const isControlled = valueProp !== undefined;
|
|
84
|
+
const [internalTags, setInternalTags] = useState<string[]>(defaultValue ?? []);
|
|
85
|
+
const [inputValue, setInputValue] = useState("");
|
|
86
|
+
const [error, setError] = useState<string | null>(null);
|
|
87
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
88
|
+
|
|
89
|
+
const tags = useMemo(
|
|
90
|
+
() => (isControlled ? (valueProp ?? []) : internalTags),
|
|
91
|
+
[isControlled, valueProp, internalTags],
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const commit = useCallback(
|
|
95
|
+
(next: string[]) => {
|
|
96
|
+
if (!isControlled) setInternalTags(next);
|
|
97
|
+
onValueChange?.(next);
|
|
98
|
+
},
|
|
99
|
+
[isControlled, onValueChange],
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
const addTag = useCallback(
|
|
103
|
+
(raw: string) => {
|
|
104
|
+
const tag = raw.trim();
|
|
105
|
+
if (!tag) return;
|
|
106
|
+
|
|
107
|
+
if (max !== undefined && tags.length >= max) {
|
|
108
|
+
setError(`Maximum of ${max} tags reached.`);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (!allowDuplicates && tags.includes(tag)) {
|
|
113
|
+
setError("Duplicate tag.");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (validate) {
|
|
118
|
+
const result = validate(tag);
|
|
119
|
+
if (result === false) return;
|
|
120
|
+
if (typeof result === "string") {
|
|
121
|
+
setError(result);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
setError(null);
|
|
127
|
+
commit([...tags, tag]);
|
|
128
|
+
setInputValue("");
|
|
129
|
+
},
|
|
130
|
+
[tags, max, allowDuplicates, validate, commit],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const removeTag = useCallback(
|
|
134
|
+
(index: number) => {
|
|
135
|
+
const next = tags.filter((_, i) => i !== index);
|
|
136
|
+
setError(null);
|
|
137
|
+
commit(next);
|
|
138
|
+
},
|
|
139
|
+
[tags, commit],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
const handleKeyDown = useCallback(
|
|
143
|
+
(e: KeyboardEvent<HTMLInputElement>) => {
|
|
144
|
+
if (e.key === "Enter") {
|
|
145
|
+
// Always prevent form submission when used inside a <form>
|
|
146
|
+
e.preventDefault();
|
|
147
|
+
addTag(inputValue);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (e.key === "Backspace" && inputValue === "" && tags.length > 0) {
|
|
152
|
+
e.preventDefault();
|
|
153
|
+
removeTag(tags.length - 1);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Check delimiter characters (e.g. comma)
|
|
158
|
+
for (const delim of delimiter) {
|
|
159
|
+
if (delim.length === 1 && e.key === delim) {
|
|
160
|
+
e.preventDefault();
|
|
161
|
+
addTag(inputValue);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
[inputValue, tags, delimiter, addTag, removeTag],
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const handleChange = useCallback(
|
|
170
|
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
171
|
+
const val = e.target.value;
|
|
172
|
+
// Handle paste or typed multi-char delimiters
|
|
173
|
+
const hasDelimiter = delimiter.some((d) => val.includes(d));
|
|
174
|
+
if (hasDelimiter) {
|
|
175
|
+
const parts = val.split(new RegExp(`[${delimiter.map((d) => escapeRegex(d)).join("")}]`));
|
|
176
|
+
const toAdd = parts.slice(0, -1);
|
|
177
|
+
const remaining = parts[parts.length - 1] ?? "";
|
|
178
|
+
for (const part of toAdd) {
|
|
179
|
+
addTag(part);
|
|
180
|
+
}
|
|
181
|
+
setInputValue(remaining);
|
|
182
|
+
} else {
|
|
183
|
+
setInputValue(val);
|
|
184
|
+
setError(null);
|
|
185
|
+
}
|
|
186
|
+
},
|
|
187
|
+
[delimiter, addTag],
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
const atMax = max !== undefined && tags.length >= max;
|
|
191
|
+
|
|
192
|
+
return (
|
|
193
|
+
<div ref={ref} className={cn("flex flex-col gap-1", className)} {...props}>
|
|
194
|
+
<InputGroup
|
|
195
|
+
className="h-auto min-h-9 flex-wrap gap-1 px-2 py-1 cursor-text"
|
|
196
|
+
onClick={() => inputRef.current?.focus()}
|
|
197
|
+
>
|
|
198
|
+
{tags.map((tag, i) => (
|
|
199
|
+
<Badge key={`${tag}-${i}`} variant="secondary" className="gap-1 pe-1 shrink-0">
|
|
200
|
+
<span className="max-w-[12rem] truncate">{tag}</span>
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
aria-label={`Remove ${tag}`}
|
|
204
|
+
disabled={disabled}
|
|
205
|
+
onClick={(e) => {
|
|
206
|
+
e.stopPropagation();
|
|
207
|
+
removeTag(i);
|
|
208
|
+
}}
|
|
209
|
+
className={cn(
|
|
210
|
+
"rounded-full p-0.5 transition-colors",
|
|
211
|
+
"hover:bg-secondary-foreground/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
212
|
+
"disabled:pointer-events-none disabled:opacity-50",
|
|
213
|
+
)}
|
|
214
|
+
>
|
|
215
|
+
<X className="size-3" aria-hidden="true" />
|
|
216
|
+
</button>
|
|
217
|
+
</Badge>
|
|
218
|
+
))}
|
|
219
|
+
<input
|
|
220
|
+
ref={inputRef}
|
|
221
|
+
id={id}
|
|
222
|
+
name={name}
|
|
223
|
+
type="text"
|
|
224
|
+
value={inputValue}
|
|
225
|
+
placeholder={atMax ? undefined : placeholder}
|
|
226
|
+
disabled={disabled || atMax}
|
|
227
|
+
aria-label={ariaLabel}
|
|
228
|
+
aria-labelledby={ariaLabelledby}
|
|
229
|
+
aria-invalid={error ? "true" : undefined}
|
|
230
|
+
data-slot="input-group-control"
|
|
231
|
+
className={cn(
|
|
232
|
+
"min-w-[6rem] flex-1 bg-transparent text-sm outline-none",
|
|
233
|
+
"placeholder:text-muted-foreground",
|
|
234
|
+
"disabled:cursor-not-allowed disabled:opacity-50",
|
|
235
|
+
)}
|
|
236
|
+
onChange={handleChange}
|
|
237
|
+
onKeyDown={handleKeyDown}
|
|
238
|
+
autoComplete="off"
|
|
239
|
+
spellCheck={false}
|
|
240
|
+
/>
|
|
241
|
+
</InputGroup>
|
|
242
|
+
{error && (
|
|
243
|
+
<p role="alert" className="text-xs text-destructive">
|
|
244
|
+
{error}
|
|
245
|
+
</p>
|
|
246
|
+
)}
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
function escapeRegex(s: string): string {
|
|
252
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
253
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { TeamSwitcher, type TeamSwitcherProps, type TeamSwitcherTeam } from "./team-switcher";
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { SidebarProvider } from "../sidebar";
|
|
4
|
+
import { TeamSwitcher } from "./team-switcher";
|
|
5
|
+
|
|
6
|
+
const IconStub = ({ className }: { className?: string }) => (
|
|
7
|
+
<svg className={className} aria-hidden="true" />
|
|
8
|
+
);
|
|
9
|
+
|
|
10
|
+
function Wrapper({ children }: { children: React.ReactNode }) {
|
|
11
|
+
return <SidebarProvider>{children}</SidebarProvider>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("TeamSwitcher", () => {
|
|
15
|
+
it("renders the active team name and plan", () => {
|
|
16
|
+
render(
|
|
17
|
+
<Wrapper>
|
|
18
|
+
<TeamSwitcher
|
|
19
|
+
teams={[
|
|
20
|
+
{ name: "Acme Inc.", logo: IconStub, plan: "Enterprise" },
|
|
21
|
+
{ name: "Beta Corp.", logo: IconStub, plan: "Free" },
|
|
22
|
+
]}
|
|
23
|
+
/>
|
|
24
|
+
</Wrapper>,
|
|
25
|
+
);
|
|
26
|
+
expect(screen.getByText("Acme Inc.")).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByText("Enterprise")).toBeInTheDocument();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("renders nothing when teams array is empty", () => {
|
|
31
|
+
const { container } = render(
|
|
32
|
+
<Wrapper>
|
|
33
|
+
<TeamSwitcher teams={[]} />
|
|
34
|
+
</Wrapper>,
|
|
35
|
+
);
|
|
36
|
+
// No active team → inner returns null, so no trigger button
|
|
37
|
+
expect(container.querySelector("[data-radix-dropdown-menu-trigger]")).toBeNull();
|
|
38
|
+
});
|
|
39
|
+
});
|