@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,339 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BentoGrid + BentoGridItem — a hover-elevation bento layout.
|
|
5
|
+
*
|
|
6
|
+
* A responsive dense CSS-grid container. **The default separation gesture is
|
|
7
|
+
* ELEVATION, not colour:** the grid rests flat (`shadow-none` — border only) and
|
|
8
|
+
* each tile rises ~4px into `shadow-xl` with a brand-tinted edge on hover, so the
|
|
9
|
+
* tile under the pointer clearly separates from the sheet.
|
|
10
|
+
*
|
|
11
|
+
* The cursor-following **spotlight is OPT-IN** (`spotlight`, default `false`) —
|
|
12
|
+
* set it on the `BentoGrid` to enable it for every tile, or per tile to override
|
|
13
|
+
* the grid. Its colour is a DERIVED tint of the `--primary` token
|
|
14
|
+
* (`color-mix(in oklch, var(--primary) 12%, transparent)`), so it stays
|
|
15
|
+
* theme-correct in every theme with no raw colors and no extra token. Under OS
|
|
16
|
+
* reduced-motion the spotlight is suppressed entirely (`motion-reduce:hidden`);
|
|
17
|
+
* tiles still respond to hover/focus with their normal card states.
|
|
18
|
+
*
|
|
19
|
+
* Key design choices:
|
|
20
|
+
* - `BentoGridItem` composes `Card` (inherits card surface/border/radius) and
|
|
21
|
+
* then overrides the resting `shadow-sm` down to `shadow-none`.
|
|
22
|
+
* - Under reduced motion the travel is neutralized and the transition dropped —
|
|
23
|
+
* the shadow and the edge still state the hover ("reduced != none").
|
|
24
|
+
* - Spans are inline `grid-column/grid-row` and collapse to one cell on the
|
|
25
|
+
* 1-column mobile grid (the browser caps a span at the available tracks).
|
|
26
|
+
* - The tile is presentational; for a clickable tile, place an interactive inner
|
|
27
|
+
* element (e.g. a stretched `<a className="absolute inset-0">`) and set
|
|
28
|
+
* `interactive` so the tile shows a `focus-within` ring.
|
|
29
|
+
* - The spotlight overlay is `pointer-events-none` + `aria-hidden` (decorative).
|
|
30
|
+
* - Motion is gated via `duration-base` / `ease-standard` (token-backed).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import {
|
|
34
|
+
createContext,
|
|
35
|
+
forwardRef,
|
|
36
|
+
use,
|
|
37
|
+
useRef,
|
|
38
|
+
useCallback,
|
|
39
|
+
type CSSProperties,
|
|
40
|
+
type HTMLAttributes,
|
|
41
|
+
} from "react";
|
|
42
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
43
|
+
import { cn } from "../../lib/cn";
|
|
44
|
+
import { cardVariants } from "../card/card";
|
|
45
|
+
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
// Context — the grid hands its spotlight default down to every tile so a whole
|
|
48
|
+
// bento can opt in with ONE prop instead of repeating it on every child. A tile's
|
|
49
|
+
// own `spotlight` prop always wins.
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
const BentoGridContext = createContext<{ spotlight: boolean }>({ spotlight: false });
|
|
53
|
+
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
// BentoGrid
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
export interface BentoGridProps extends HTMLAttributes<HTMLDivElement> {
|
|
59
|
+
/** Additional className merged via cn(). */
|
|
60
|
+
className?: string;
|
|
61
|
+
/**
|
|
62
|
+
* Enable the cursor-following spotlight on every tile in this grid. Individual
|
|
63
|
+
* tiles can still opt out (or in) with their own `spotlight` prop. Disabled
|
|
64
|
+
* entirely under OS `prefers-reduced-motion` regardless of this prop.
|
|
65
|
+
* @default false
|
|
66
|
+
*/
|
|
67
|
+
spotlight?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Responsive CSS-grid container.
|
|
72
|
+
* - 1 column on mobile, 2 on sm, 4 on lg.
|
|
73
|
+
* - `grid-auto-flow: dense` fills in gaps left by spanning tiles.
|
|
74
|
+
* - `auto-rows-[14rem]` gives a baseline row height (14rem ≈ 224px); tiles can
|
|
75
|
+
* span multiple rows via the `size` / `span.row` API.
|
|
76
|
+
* - Carries the grid-wide `spotlight` default for its tiles (opt-in).
|
|
77
|
+
*/
|
|
78
|
+
export const BentoGrid = forwardRef<HTMLDivElement, BentoGridProps>(function BentoGrid(
|
|
79
|
+
{ className, spotlight = false, children, ...props },
|
|
80
|
+
ref,
|
|
81
|
+
) {
|
|
82
|
+
return (
|
|
83
|
+
<BentoGridContext value={{ spotlight }}>
|
|
84
|
+
<div
|
|
85
|
+
ref={ref}
|
|
86
|
+
data-slot="bento-grid"
|
|
87
|
+
className={cn(
|
|
88
|
+
"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4",
|
|
89
|
+
"[grid-auto-flow:dense]",
|
|
90
|
+
"auto-rows-[14rem]",
|
|
91
|
+
"gap-4",
|
|
92
|
+
className,
|
|
93
|
+
)}
|
|
94
|
+
{...props}
|
|
95
|
+
>
|
|
96
|
+
{children}
|
|
97
|
+
</div>
|
|
98
|
+
</BentoGridContext>
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// BentoGridItem — size / span presets
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Size preset → [colSpan, rowSpan].
|
|
108
|
+
*
|
|
109
|
+
* | size | cols | rows | purpose |
|
|
110
|
+
* |-------|------|------|------------------------------------------------------------|
|
|
111
|
+
* | sm | 1 | 1 | Supporting: compact metric or icon tile |
|
|
112
|
+
* | md | 2 | 1 | Supporting: wider but single-row |
|
|
113
|
+
* | lg | 2 | 2 | Featured: 2×2 block |
|
|
114
|
+
* | hero | 2 | 2 | Hero: 2×2 + gradient emphasis wash |
|
|
115
|
+
*
|
|
116
|
+
* On a 1-column layout (mobile) ALL col spans clamp to 1 automatically via
|
|
117
|
+
* CSS `min()` in `gridColumn`. Row spans are always honoured.
|
|
118
|
+
*/
|
|
119
|
+
export type BentoGridSize = "sm" | "md" | "lg" | "hero";
|
|
120
|
+
|
|
121
|
+
const SIZE_SPANS: Record<BentoGridSize, { col: number; row: number }> = {
|
|
122
|
+
sm: { col: 1, row: 1 },
|
|
123
|
+
md: { col: 2, row: 1 },
|
|
124
|
+
lg: { col: 2, row: 2 },
|
|
125
|
+
hero: { col: 2, row: 2 },
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
// cva for the item's structural/visual axes (NOT size — span is applied via
|
|
129
|
+
// inline style so it collapses correctly on mobile without media-query gymnastics).
|
|
130
|
+
export const bentoGridItemVariants = cva(
|
|
131
|
+
[
|
|
132
|
+
// Base: compose Card surface (bg-card border rounded-lg shadow-sm).
|
|
133
|
+
cardVariants({ interactive: false }),
|
|
134
|
+
// Overflow hidden so content + spotlight overlay stay within rounded corners.
|
|
135
|
+
"relative overflow-hidden",
|
|
136
|
+
// Flex column layout for slot content.
|
|
137
|
+
"flex flex-col",
|
|
138
|
+
// Ensure long unbroken strings can't overflow the tile.
|
|
139
|
+
"min-w-0 break-words",
|
|
140
|
+
// ELEVATION IS THE HOVER GESTURE. The grid rests FLAT — `shadow-none` beats
|
|
141
|
+
// the `shadow-sm` inherited from `cardVariants` (tailwind-merge, later wins)
|
|
142
|
+
// so a bento sheet reads as one plane, not a field of lifted chips. On hover
|
|
143
|
+
// the single tile under the pointer rises to `shadow-lg`, a stacked rung of
|
|
144
|
+
// the ONE elevation ramp (never a hand-rolled box-shadow). The rung is
|
|
145
|
+
// `hover:`-prefixed, so pairing it with the card border is the sanctioned
|
|
146
|
+
// "bordered card, hover lift" shape, not the flagged double edge.
|
|
147
|
+
"shadow-none hover:shadow-xl",
|
|
148
|
+
// Shadow alone is a LIGHT-theme signal: the ramp's ink is black, so on a dark
|
|
149
|
+
// ground even `shadow-xl` is faint (measured on `theme:dark` — the hovered tile
|
|
150
|
+
// was hard to pick out). The hover EDGE carries the lift there. It is
|
|
151
|
+
// `ring/40`, the same tint `Card interactive` uses — NOT `border-strong`, which
|
|
152
|
+
// is a near-charcoal 0.65 L against a 0.88 L resting border and reads as a hard
|
|
153
|
+
// outline snapping on, not as a lift. At 40% the brand hue stays a soft warm
|
|
154
|
+
// edge on white and a clearly brighter one on charcoal.
|
|
155
|
+
"hover:border-ring/40",
|
|
156
|
+
// A lift you can SEE: ~4px of travel. Below ~16px a slide is a flicker, but a
|
|
157
|
+
// hover-lift is the exception the motion guidelines size at ≥4px — and the
|
|
158
|
+
// travel, not the duration, is what makes the elevation legible.
|
|
159
|
+
"hover:-translate-y-1 motion-reduce:hover:translate-y-0",
|
|
160
|
+
// `ease-entrance` (easeOutQuint) — a near-zero arrival velocity is the #1
|
|
161
|
+
// smoothness lever; `ease-standard` snaps at the end. Duration stays at the
|
|
162
|
+
// 260ms `base` rung: lengthening it does NOT fix abruptness. Under OS
|
|
163
|
+
// reduced-motion the travel is neutralized and the transition is dropped, so
|
|
164
|
+
// the shadow + edge still state the hover without any movement.
|
|
165
|
+
"transition-[translate,box-shadow,border-color] duration-base ease-entrance motion-reduce:transition-none",
|
|
166
|
+
].join(" "),
|
|
167
|
+
{
|
|
168
|
+
variants: {
|
|
169
|
+
/** Hero tile gets a subtle gradient wash to distinguish it from supporting tiles. */
|
|
170
|
+
hero: {
|
|
171
|
+
true: "bg-gradient-to-br from-primary/8 to-card",
|
|
172
|
+
false: "",
|
|
173
|
+
},
|
|
174
|
+
/**
|
|
175
|
+
* Interactive tiles (those holding a clickable inner element, e.g. a
|
|
176
|
+
* stretched link) get a pointer cursor and a `focus-within` ring so keyboard
|
|
177
|
+
* focus on the inner control is visible on the whole tile. The hover lift
|
|
178
|
+
* (elevation + edge) is on the base — every tile lifts, clickable or not.
|
|
179
|
+
*/
|
|
180
|
+
interactive: {
|
|
181
|
+
true: "cursor-pointer focus-within:outline-none focus-within:ring-2 focus-within:ring-ring",
|
|
182
|
+
false: "",
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
defaultVariants: { hero: false, interactive: false },
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
export type BentoGridItemVariants = VariantProps<typeof bentoGridItemVariants>;
|
|
190
|
+
|
|
191
|
+
export interface BentoGridItemProps extends HTMLAttributes<HTMLDivElement>, BentoGridItemVariants {
|
|
192
|
+
/**
|
|
193
|
+
* Size preset. Resolves to a col + row span. Overridden by explicit `span`.
|
|
194
|
+
* @default "sm"
|
|
195
|
+
*/
|
|
196
|
+
size?: BentoGridSize;
|
|
197
|
+
/**
|
|
198
|
+
* Explicit grid span. Wins over `size`. Spans clamp gracefully on narrow
|
|
199
|
+
* layouts — a `col: 4` on a 2-col grid becomes `span min(4, 2)`.
|
|
200
|
+
*/
|
|
201
|
+
span?: { col?: number; row?: number };
|
|
202
|
+
/**
|
|
203
|
+
* Enable the cursor-following spotlight gradient on this tile. Omit to inherit
|
|
204
|
+
* the parent `BentoGrid`'s `spotlight` (itself `false` by default) — so the
|
|
205
|
+
* effect is opt-in, per grid or per tile. Disabled entirely under OS
|
|
206
|
+
* `prefers-reduced-motion` regardless of this prop.
|
|
207
|
+
* @default false (inherited from BentoGrid)
|
|
208
|
+
*/
|
|
209
|
+
spotlight?: boolean;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* A single bento tile. Composes `Card`, rests flat, lifts to `shadow-lg` on
|
|
214
|
+
* hover, and adds the OPT-IN spotlight overlay.
|
|
215
|
+
*
|
|
216
|
+
* Spotlight:
|
|
217
|
+
* - Sets `--bento-x` / `--bento-y` CSS custom properties on `onMouseMove`
|
|
218
|
+
* via a direct style set (no layout reads in render — uses
|
|
219
|
+
* `getBoundingClientRect` in the event handler only, never during paint).
|
|
220
|
+
* - An absolutely-positioned `pointer-events-none aria-hidden` overlay paints
|
|
221
|
+
* `radial-gradient(… at var(--bento-x) var(--bento-y), color-mix(--primary), transparent)`.
|
|
222
|
+
* - Revealed on hover via opacity transition gated by `duration-base ease-standard`.
|
|
223
|
+
* - Suppressed with `motion-reduce:hidden` — cursor-following IS motion.
|
|
224
|
+
*/
|
|
225
|
+
export const BentoGridItem = forwardRef<HTMLDivElement, BentoGridItemProps>(function BentoGridItem(
|
|
226
|
+
{ size = "sm", span, spotlight, hero, interactive, className, style, children, ...props },
|
|
227
|
+
ref,
|
|
228
|
+
) {
|
|
229
|
+
const tileRef = useRef<HTMLDivElement>(null);
|
|
230
|
+
const overlayRef = useRef<HTMLDivElement>(null);
|
|
231
|
+
|
|
232
|
+
// Own prop wins; otherwise inherit the grid's opt-in (default false).
|
|
233
|
+
const spotlightOn = spotlight ?? use(BentoGridContext).spotlight;
|
|
234
|
+
|
|
235
|
+
// Resolve spans: explicit span wins over size preset.
|
|
236
|
+
const resolvedCol = span?.col ?? SIZE_SPANS[size].col;
|
|
237
|
+
const resolvedRow = span?.row ?? SIZE_SPANS[size].row;
|
|
238
|
+
|
|
239
|
+
const isInteractive = interactive ?? false;
|
|
240
|
+
|
|
241
|
+
// isHero: explicit prop or size === "hero".
|
|
242
|
+
const isHero = hero ?? size === "hero";
|
|
243
|
+
|
|
244
|
+
// rAF ref so we only schedule one frame at a time.
|
|
245
|
+
const rafRef = useRef<number>(0);
|
|
246
|
+
|
|
247
|
+
const handleMouseMove = useCallback(
|
|
248
|
+
(e: React.MouseEvent<HTMLDivElement>) => {
|
|
249
|
+
if (!spotlightOn || !tileRef.current || !overlayRef.current) return;
|
|
250
|
+
// Cancel pending rAF to debounce rapid moves.
|
|
251
|
+
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
|
252
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
253
|
+
if (!tileRef.current || !overlayRef.current) return;
|
|
254
|
+
const rect = tileRef.current.getBoundingClientRect();
|
|
255
|
+
const x = e.clientX - rect.left;
|
|
256
|
+
const y = e.clientY - rect.top;
|
|
257
|
+
// Direct style mutation — no React render cycle.
|
|
258
|
+
tileRef.current.style.setProperty("--bento-x", `${x}px`);
|
|
259
|
+
tileRef.current.style.setProperty("--bento-y", `${y}px`);
|
|
260
|
+
overlayRef.current.style.opacity = "1";
|
|
261
|
+
});
|
|
262
|
+
},
|
|
263
|
+
[spotlightOn],
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
const handleMouseLeave = useCallback(() => {
|
|
267
|
+
if (!overlayRef.current) return;
|
|
268
|
+
overlayRef.current.style.opacity = "0";
|
|
269
|
+
}, []);
|
|
270
|
+
|
|
271
|
+
// Grid span via inline style. On mobile (grid-cols-1) a col-span of any value
|
|
272
|
+
// is effectively clamped to 1 by the grid itself — a 4-col tile in a 1-col
|
|
273
|
+
// grid still renders in one cell. Row spans are always honoured as-is.
|
|
274
|
+
// We use `gridColumn: "span N / span N"` which the browser safely caps at the
|
|
275
|
+
// available track count; no CSS min() with custom-property arithmetic needed.
|
|
276
|
+
const gridStyle: CSSProperties = {
|
|
277
|
+
gridColumn: resolvedCol > 1 ? `span ${resolvedCol} / span ${resolvedCol}` : undefined,
|
|
278
|
+
gridRow: resolvedRow > 1 ? `span ${resolvedRow} / span ${resolvedRow}` : undefined,
|
|
279
|
+
...style,
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
// Merge the forwarded ref and the internal tileRef into one callback ref.
|
|
283
|
+
const mergedRef = useCallback(
|
|
284
|
+
(node: HTMLDivElement | null) => {
|
|
285
|
+
(tileRef as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
286
|
+
if (typeof ref === "function") {
|
|
287
|
+
ref(node);
|
|
288
|
+
} else if (ref) {
|
|
289
|
+
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
[ref],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div
|
|
297
|
+
ref={mergedRef as React.Ref<HTMLDivElement>}
|
|
298
|
+
className={cn(
|
|
299
|
+
bentoGridItemVariants({
|
|
300
|
+
hero: isHero,
|
|
301
|
+
interactive: isInteractive,
|
|
302
|
+
}),
|
|
303
|
+
className,
|
|
304
|
+
)}
|
|
305
|
+
style={gridStyle}
|
|
306
|
+
data-slot="bento-grid-item"
|
|
307
|
+
onMouseMove={handleMouseMove}
|
|
308
|
+
onMouseLeave={handleMouseLeave}
|
|
309
|
+
{...props}
|
|
310
|
+
>
|
|
311
|
+
{/* Spotlight overlay — OPT-IN, decorative, pointer-events-none, aria-hidden.
|
|
312
|
+
Hidden in full under reduced-motion (the cursor-following IS the motion).
|
|
313
|
+
Color is a color-mix tint of --primary (token-derived, no raw color). */}
|
|
314
|
+
{spotlightOn && (
|
|
315
|
+
<div
|
|
316
|
+
ref={overlayRef}
|
|
317
|
+
aria-hidden="true"
|
|
318
|
+
data-testid="bento-spotlight"
|
|
319
|
+
className={cn(
|
|
320
|
+
"pointer-events-none absolute inset-0 z-10",
|
|
321
|
+
"opacity-0 transition-opacity duration-base ease-standard",
|
|
322
|
+
// Suppress entirely under reduced-motion (cursor tracking = motion).
|
|
323
|
+
"motion-reduce:hidden",
|
|
324
|
+
)}
|
|
325
|
+
style={{
|
|
326
|
+
// Spotlight color is a DERIVED tint of the --primary token (not a raw
|
|
327
|
+
// literal), so it stays theme-correct in every theme without a new
|
|
328
|
+
// token. `color-mix` keeps it ~12% primary over transparent.
|
|
329
|
+
background:
|
|
330
|
+
"radial-gradient(400px circle at var(--bento-x, 50%) var(--bento-y, 50%), color-mix(in oklch, var(--primary) 12%, transparent), transparent 70%)",
|
|
331
|
+
}}
|
|
332
|
+
/>
|
|
333
|
+
)}
|
|
334
|
+
{/* Tile content — min-w-0 on inner wrapper prevents flex children from
|
|
335
|
+
overflowing on long unbroken strings (the silent truncation culprit). */}
|
|
336
|
+
<div className="relative z-20 flex min-w-0 flex-1 flex-col">{children}</div>
|
|
337
|
+
</div>
|
|
338
|
+
);
|
|
339
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect } from "storybook/test";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { BoundedNumber } from "./bounded-number";
|
|
5
|
+
|
|
6
|
+
const meta = {
|
|
7
|
+
title: "Forms/BoundedNumber",
|
|
8
|
+
component: BoundedNumber,
|
|
9
|
+
tags: ["autodocs"],
|
|
10
|
+
args: { min: 0, max: 1000, step: 10 },
|
|
11
|
+
argTypes: {
|
|
12
|
+
value: {
|
|
13
|
+
description: 'Controlled numeric value. Pass `null` (or leave empty) for "No limit".',
|
|
14
|
+
control: "number",
|
|
15
|
+
table: { category: "State" },
|
|
16
|
+
},
|
|
17
|
+
defaultValue: {
|
|
18
|
+
description: "Uncontrolled initial value.",
|
|
19
|
+
control: "number",
|
|
20
|
+
table: { category: "State" },
|
|
21
|
+
},
|
|
22
|
+
emptyLabel: {
|
|
23
|
+
description: 'Content shown instead of the value when empty (default "No limit").',
|
|
24
|
+
control: "text",
|
|
25
|
+
table: { category: "Content" },
|
|
26
|
+
},
|
|
27
|
+
min: {
|
|
28
|
+
description: "Minimum allowed value.",
|
|
29
|
+
control: "number",
|
|
30
|
+
table: { category: "Behavior" },
|
|
31
|
+
},
|
|
32
|
+
max: {
|
|
33
|
+
description: "Maximum allowed value.",
|
|
34
|
+
control: "number",
|
|
35
|
+
table: { category: "Behavior" },
|
|
36
|
+
},
|
|
37
|
+
step: {
|
|
38
|
+
description: "Increment/decrement amount per click or arrow key.",
|
|
39
|
+
control: "number",
|
|
40
|
+
table: { category: "Behavior" },
|
|
41
|
+
},
|
|
42
|
+
disabled: {
|
|
43
|
+
description: "Disables all interaction.",
|
|
44
|
+
control: "boolean",
|
|
45
|
+
table: { category: "State" },
|
|
46
|
+
},
|
|
47
|
+
onValueChange: {
|
|
48
|
+
description: "Called when the numeric value changes.",
|
|
49
|
+
control: false,
|
|
50
|
+
table: { category: "Behavior" },
|
|
51
|
+
},
|
|
52
|
+
className: {
|
|
53
|
+
description: "Additional CSS classes applied to the underlying NumberInput.",
|
|
54
|
+
control: "text",
|
|
55
|
+
table: { category: "Appearance" },
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
} satisfies Meta<typeof BoundedNumber>;
|
|
59
|
+
export default meta;
|
|
60
|
+
type Story = StoryObj<typeof meta>;
|
|
61
|
+
|
|
62
|
+
/** At rest (unfocused, empty) the field reads "No limit" instead of a blank box. */
|
|
63
|
+
export const Default: Story = {
|
|
64
|
+
args: { defaultValue: null, "aria-label": "Rate limit" },
|
|
65
|
+
play: async ({ canvas }) => {
|
|
66
|
+
await expect(canvas.getByText("No limit")).toBeInTheDocument();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const WithValue: Story = {
|
|
71
|
+
args: { defaultValue: 250, "aria-label": "Rate limit" },
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const CustomEmptyLabel: Story = {
|
|
75
|
+
args: { defaultValue: null, emptyLabel: "Unlimited", "aria-label": "Rate limit" },
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Typing a value and clearing it back: focusing hides the label so the field
|
|
80
|
+
* reads as truly empty while editing, and blurring an empty field restores
|
|
81
|
+
* the label (the value round-trips to `null`, never 0 or the max).
|
|
82
|
+
*/
|
|
83
|
+
export const TypeThenClear: Story = {
|
|
84
|
+
render: () => {
|
|
85
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
86
|
+
const [value, setValue] = useState<number | null>(null);
|
|
87
|
+
return (
|
|
88
|
+
<div className="flex flex-col gap-2">
|
|
89
|
+
<BoundedNumber
|
|
90
|
+
aria-label="Rate limit"
|
|
91
|
+
value={value}
|
|
92
|
+
onValueChange={setValue}
|
|
93
|
+
min={0}
|
|
94
|
+
max={1000}
|
|
95
|
+
step={10}
|
|
96
|
+
/>
|
|
97
|
+
<p className="text-body text-muted-foreground">Value: {value ?? "null"}</p>
|
|
98
|
+
</div>
|
|
99
|
+
);
|
|
100
|
+
},
|
|
101
|
+
play: async ({ canvas, userEvent }) => {
|
|
102
|
+
const input = canvas.getByRole("spinbutton", { name: "Rate limit" });
|
|
103
|
+
await userEvent.click(input);
|
|
104
|
+
await expect(canvas.queryByText("No limit")).not.toBeInTheDocument();
|
|
105
|
+
await userEvent.type(input, "500");
|
|
106
|
+
await userEvent.tab();
|
|
107
|
+
await expect(canvas.getByText("Value: 500")).toBeInTheDocument();
|
|
108
|
+
await userEvent.click(input);
|
|
109
|
+
await userEvent.clear(input);
|
|
110
|
+
await userEvent.tab();
|
|
111
|
+
await expect(canvas.getByText("Value: null")).toBeInTheDocument();
|
|
112
|
+
await expect(canvas.getByText("No limit")).toBeInTheDocument();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export const Disabled: Story = {
|
|
117
|
+
args: { defaultValue: null, disabled: true, "aria-label": "Rate limit" },
|
|
118
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
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 { BoundedNumber } from "./bounded-number";
|
|
5
|
+
|
|
6
|
+
describe("BoundedNumber", () => {
|
|
7
|
+
it("renders the empty label instead of a blank box when value is null", () => {
|
|
8
|
+
render(<BoundedNumber aria-label="Limit" defaultValue={null} />);
|
|
9
|
+
expect(screen.getByText("No limit")).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("supports a custom emptyLabel", () => {
|
|
13
|
+
render(<BoundedNumber aria-label="Limit" defaultValue={null} emptyLabel="Unlimited" />);
|
|
14
|
+
expect(screen.getByText("Unlimited")).toBeInTheDocument();
|
|
15
|
+
expect(screen.queryByText("No limit")).not.toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("hides the empty label once a value is set", () => {
|
|
19
|
+
render(<BoundedNumber aria-label="Limit" defaultValue={250} />);
|
|
20
|
+
expect(screen.queryByText("No limit")).not.toBeInTheDocument();
|
|
21
|
+
expect(screen.getByRole("spinbutton", { name: "Limit" })).toHaveValue(250);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("hides the empty label while focused so the field reads as truly empty", async () => {
|
|
25
|
+
render(<BoundedNumber aria-label="Limit" defaultValue={null} />);
|
|
26
|
+
const input = screen.getByRole("spinbutton", { name: "Limit" });
|
|
27
|
+
await userEvent.click(input);
|
|
28
|
+
expect(screen.queryByText("No limit")).not.toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("round-trips to null (never 0 or max) when cleared and blurred", async () => {
|
|
32
|
+
const onValueChange = vi.fn();
|
|
33
|
+
render(
|
|
34
|
+
<BoundedNumber
|
|
35
|
+
aria-label="Limit"
|
|
36
|
+
defaultValue={100}
|
|
37
|
+
max={1000}
|
|
38
|
+
onValueChange={onValueChange}
|
|
39
|
+
/>,
|
|
40
|
+
);
|
|
41
|
+
const input = screen.getByRole("spinbutton", { name: "Limit" });
|
|
42
|
+
await userEvent.clear(input);
|
|
43
|
+
await userEvent.tab();
|
|
44
|
+
expect(onValueChange).toHaveBeenLastCalledWith(null);
|
|
45
|
+
expect(screen.getByText("No limit")).toBeInTheDocument();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("supports the controlled mode via value/onValueChange", async () => {
|
|
49
|
+
const onValueChange = vi.fn();
|
|
50
|
+
render(<BoundedNumber aria-label="Limit" value={null} onValueChange={onValueChange} />);
|
|
51
|
+
const input = screen.getByRole("spinbutton", { name: "Limit" });
|
|
52
|
+
await userEvent.click(input);
|
|
53
|
+
await userEvent.type(input, "42");
|
|
54
|
+
await userEvent.tab();
|
|
55
|
+
expect(onValueChange).toHaveBeenLastCalledWith(42);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("is disabled when disabled prop is set", () => {
|
|
59
|
+
render(<BoundedNumber aria-label="Limit" defaultValue={null} disabled />);
|
|
60
|
+
expect(screen.getByRole("spinbutton", { name: "Limit" })).toBeDisabled();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("merges className onto the ROOT so a caller can size the field", () => {
|
|
64
|
+
const { container } = render(
|
|
65
|
+
<BoundedNumber aria-label="Limit" defaultValue={null} className="w-full" />,
|
|
66
|
+
);
|
|
67
|
+
const root = container.querySelector('[data-slot="bounded-number"]');
|
|
68
|
+
// A width utility on the inner control would resolve against a
|
|
69
|
+
// shrink-to-fit wrapper and do nothing — it has to land on the root.
|
|
70
|
+
expect(root).toHaveClass("w-full");
|
|
71
|
+
expect(root).not.toHaveClass("w-36");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { forwardRef, useCallback, useState, type FocusEvent, type ReactNode } from "react";
|
|
4
|
+
import { cn } from "../../lib/cn";
|
|
5
|
+
import { NumberInput, type NumberInputProps } from "../number-input";
|
|
6
|
+
import { useLocale } from "../locale-provider";
|
|
7
|
+
|
|
8
|
+
export interface BoundedNumberProps extends NumberInputProps {
|
|
9
|
+
/**
|
|
10
|
+
* Content shown in place of the value when the field is empty (`null`) and
|
|
11
|
+
* not focused — e.g. "No limit" for a capped setting with no cap set.
|
|
12
|
+
* Defaults to "No limit".
|
|
13
|
+
*/
|
|
14
|
+
emptyLabel?: ReactNode;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* BoundedNumber — a `NumberInput` preset for a bounded numeric where "empty"
|
|
19
|
+
* has a semantic meaning ("No limit") instead of reading as a blank box.
|
|
20
|
+
*
|
|
21
|
+
* Written reuse verdict (see #373): this wraps `NumberInput` rather than
|
|
22
|
+
* reimplementing anything. `NumberInput` already does everything the value
|
|
23
|
+
* needs — blur-only clamping (`clamp`, default true) and `null` as a real,
|
|
24
|
+
* distinct empty state (`commit(null)` on empty blur). The only missing
|
|
25
|
+
* behaviour is rendering that empty state as a meaningful label instead of a
|
|
26
|
+
* blank input, which is exactly what this preset adds: an absolutely
|
|
27
|
+
* positioned, `pointer-events-none` overlay shown only while the value is
|
|
28
|
+
* `null` AND the field is not focused, so the underlying input stays fully
|
|
29
|
+
* clickable/typable and still round-trips to `null` on an empty blur.
|
|
30
|
+
*/
|
|
31
|
+
export const BoundedNumber = forwardRef<HTMLDivElement, BoundedNumberProps>(function BoundedNumber(
|
|
32
|
+
{
|
|
33
|
+
value: valueProp,
|
|
34
|
+
defaultValue,
|
|
35
|
+
onValueChange,
|
|
36
|
+
emptyLabel,
|
|
37
|
+
onFocus,
|
|
38
|
+
onBlur,
|
|
39
|
+
className,
|
|
40
|
+
...props
|
|
41
|
+
},
|
|
42
|
+
ref,
|
|
43
|
+
) {
|
|
44
|
+
const { t } = useLocale();
|
|
45
|
+
const resolvedEmptyLabel = emptyLabel ?? t("ui.boundedNumber.emptyLabel");
|
|
46
|
+
const isControlled = valueProp !== undefined;
|
|
47
|
+
const [internalValue, setInternalValue] = useState<number | null>(
|
|
48
|
+
defaultValue !== undefined ? defaultValue : null,
|
|
49
|
+
);
|
|
50
|
+
const [focused, setFocused] = useState(false);
|
|
51
|
+
const currentValue = isControlled ? valueProp : internalValue;
|
|
52
|
+
|
|
53
|
+
const handleValueChange = useCallback(
|
|
54
|
+
(next: number | null) => {
|
|
55
|
+
if (!isControlled) setInternalValue(next);
|
|
56
|
+
onValueChange?.(next);
|
|
57
|
+
},
|
|
58
|
+
[isControlled, onValueChange],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const handleFocus = useCallback(
|
|
62
|
+
(event: FocusEvent<HTMLDivElement>) => {
|
|
63
|
+
setFocused(true);
|
|
64
|
+
onFocus?.(event);
|
|
65
|
+
},
|
|
66
|
+
[onFocus],
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const handleBlur = useCallback(
|
|
70
|
+
(event: FocusEvent<HTMLDivElement>) => {
|
|
71
|
+
setFocused(false);
|
|
72
|
+
onBlur?.(event);
|
|
73
|
+
},
|
|
74
|
+
[onBlur],
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const showEmptyLabel = currentValue == null && !focused;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
// `className` lands on the ROOT (the positioning context), not on the
|
|
81
|
+
// inner `NumberInput`: a width utility on the inner control would resolve
|
|
82
|
+
// against a shrink-to-fit wrapper and silently do nothing, so
|
|
83
|
+
// `className="w-full"` could never widen the field. `w-36` here is
|
|
84
|
+
// `NumberInput`'s own default width, re-declared so `cn()` lets the caller
|
|
85
|
+
// override it; the input then simply fills the root.
|
|
86
|
+
<div className={cn("relative inline-block w-36", className)} data-slot="bounded-number">
|
|
87
|
+
<NumberInput
|
|
88
|
+
ref={ref}
|
|
89
|
+
value={currentValue}
|
|
90
|
+
onValueChange={handleValueChange}
|
|
91
|
+
onFocus={handleFocus}
|
|
92
|
+
onBlur={handleBlur}
|
|
93
|
+
className="w-full"
|
|
94
|
+
{...props}
|
|
95
|
+
/>
|
|
96
|
+
{showEmptyLabel && (
|
|
97
|
+
<span
|
|
98
|
+
aria-hidden="true"
|
|
99
|
+
data-slot="bounded-number-empty-label"
|
|
100
|
+
className={cn(
|
|
101
|
+
"pointer-events-none absolute inset-0 flex items-center justify-center",
|
|
102
|
+
"text-body text-muted-foreground",
|
|
103
|
+
)}
|
|
104
|
+
>
|
|
105
|
+
{resolvedEmptyLabel}
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
);
|
|
110
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { BoundedNumber, type BoundedNumberProps } from "./bounded-number";
|