@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 @@
|
|
|
1
|
+
export { ScrollArea, ScrollBar } from "./scroll-area";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { ScrollArea } from "./scroll-area";
|
|
3
|
+
const meta = {
|
|
4
|
+
title: "Display/ScrollArea",
|
|
5
|
+
component: ScrollArea,
|
|
6
|
+
tags: ["autodocs"],
|
|
7
|
+
argTypes: {
|
|
8
|
+
type: {
|
|
9
|
+
description: "Controls scrollbar visibility: `auto` (default), `always`, `scroll`, `hover`.",
|
|
10
|
+
control: { type: "select" },
|
|
11
|
+
options: ["auto", "always", "scroll", "hover"],
|
|
12
|
+
table: { category: "Behavior" },
|
|
13
|
+
},
|
|
14
|
+
scrollHideDelay: {
|
|
15
|
+
description:
|
|
16
|
+
"Duration (ms) before the scrollbar hides (applicable to `scroll` and `hover` types).",
|
|
17
|
+
control: "number",
|
|
18
|
+
table: { category: "Behavior" },
|
|
19
|
+
},
|
|
20
|
+
dir: {
|
|
21
|
+
description: "Reading direction — affects scrollbar placement.",
|
|
22
|
+
control: { type: "radio" },
|
|
23
|
+
options: ["ltr", "rtl"],
|
|
24
|
+
table: { category: "Behavior" },
|
|
25
|
+
},
|
|
26
|
+
className: {
|
|
27
|
+
description: "Extra Tailwind classes merged via cn() on the root.",
|
|
28
|
+
control: "text",
|
|
29
|
+
table: { category: "Appearance" },
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
} satisfies Meta<typeof ScrollArea>;
|
|
33
|
+
export default meta;
|
|
34
|
+
type Story = StoryObj<typeof meta>;
|
|
35
|
+
export const Default: Story = {
|
|
36
|
+
render: () => (
|
|
37
|
+
<ScrollArea className="h-40 w-56 rounded-md border p-3">
|
|
38
|
+
<div className="space-y-2 text-body">
|
|
39
|
+
{Array.from({ length: 20 }).map((_, i) => (
|
|
40
|
+
<p key={i}>Row {i + 1}</p>
|
|
41
|
+
))}
|
|
42
|
+
</div>
|
|
43
|
+
</ScrollArea>
|
|
44
|
+
),
|
|
45
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { forwardRef, type ComponentPropsWithoutRef, type ElementRef } from "react";
|
|
2
|
+
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area";
|
|
3
|
+
import { cn } from "../../lib/cn";
|
|
4
|
+
|
|
5
|
+
export const ScrollArea = forwardRef<
|
|
6
|
+
ElementRef<typeof ScrollAreaPrimitive.Root>,
|
|
7
|
+
ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root>
|
|
8
|
+
>(function ScrollArea({ className, children, ...props }, ref) {
|
|
9
|
+
return (
|
|
10
|
+
<ScrollAreaPrimitive.Root
|
|
11
|
+
ref={ref}
|
|
12
|
+
className={cn("relative overflow-hidden", className)}
|
|
13
|
+
{...props}
|
|
14
|
+
>
|
|
15
|
+
<ScrollAreaPrimitive.Viewport className="size-full rounded-[inherit]">
|
|
16
|
+
{children}
|
|
17
|
+
</ScrollAreaPrimitive.Viewport>
|
|
18
|
+
<ScrollBar />
|
|
19
|
+
<ScrollAreaPrimitive.Corner />
|
|
20
|
+
</ScrollAreaPrimitive.Root>
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const ScrollBar = forwardRef<
|
|
25
|
+
ElementRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>,
|
|
26
|
+
ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
27
|
+
>(function ScrollBar({ className, orientation = "vertical", ...props }, ref) {
|
|
28
|
+
return (
|
|
29
|
+
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
|
30
|
+
ref={ref}
|
|
31
|
+
orientation={orientation}
|
|
32
|
+
className={cn(
|
|
33
|
+
"flex touch-none select-none transition-colors",
|
|
34
|
+
orientation === "vertical" && "h-full w-2.5 border-s border-s-transparent p-px",
|
|
35
|
+
orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent p-px",
|
|
36
|
+
className,
|
|
37
|
+
)}
|
|
38
|
+
{...props}
|
|
39
|
+
>
|
|
40
|
+
<ScrollAreaPrimitive.ScrollAreaThumb className="relative flex-1 rounded-full bg-border" />
|
|
41
|
+
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
|
42
|
+
);
|
|
43
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { SectionHeader, type SectionHeaderProps } from "./section-header";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { Button } from "../button";
|
|
3
|
+
import { SectionHeader } from "./section-header";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "Layout/SectionHeader",
|
|
7
|
+
component: SectionHeader,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
} satisfies Meta<typeof SectionHeader>;
|
|
10
|
+
export default meta;
|
|
11
|
+
type Story = StoryObj<typeof meta>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = {
|
|
14
|
+
args: {
|
|
15
|
+
eyebrow: "Workspace",
|
|
16
|
+
title: "Projects",
|
|
17
|
+
description: "Everything your team is working on.",
|
|
18
|
+
actions: <Button size="sm">New project</Button>,
|
|
19
|
+
},
|
|
20
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../../lib/cn";
|
|
3
|
+
|
|
4
|
+
export interface SectionHeaderProps {
|
|
5
|
+
title: ReactNode;
|
|
6
|
+
description?: ReactNode;
|
|
7
|
+
/** Right-aligned actions (buttons, menus). */
|
|
8
|
+
actions?: ReactNode;
|
|
9
|
+
/** Optional element rendered above the title (eyebrow / breadcrumbs). */
|
|
10
|
+
eyebrow?: ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A consistent page/section heading row with optional actions. */
|
|
15
|
+
export function SectionHeader({
|
|
16
|
+
title,
|
|
17
|
+
description,
|
|
18
|
+
actions,
|
|
19
|
+
eyebrow,
|
|
20
|
+
className,
|
|
21
|
+
}: SectionHeaderProps) {
|
|
22
|
+
return (
|
|
23
|
+
<div className={cn("flex flex-wrap items-end justify-between gap-4", className)}>
|
|
24
|
+
<div className="space-y-1">
|
|
25
|
+
{eyebrow ? (
|
|
26
|
+
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
|
27
|
+
{eyebrow}
|
|
28
|
+
</div>
|
|
29
|
+
) : null}
|
|
30
|
+
<h2 className="text-title text-foreground">{title}</h2>
|
|
31
|
+
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
|
32
|
+
</div>
|
|
33
|
+
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect } from "storybook/test";
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { SegmentedField } from "./segmented-field";
|
|
5
|
+
|
|
6
|
+
const PRIORITY_OPTIONS = [
|
|
7
|
+
{ value: "low", label: "Low" },
|
|
8
|
+
{ value: "medium", label: "Medium" },
|
|
9
|
+
{ value: "high", label: "High" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
const meta = {
|
|
13
|
+
title: "Forms/SegmentedField",
|
|
14
|
+
component: SegmentedField,
|
|
15
|
+
tags: ["autodocs"],
|
|
16
|
+
args: {
|
|
17
|
+
label: "Priority",
|
|
18
|
+
options: PRIORITY_OPTIONS,
|
|
19
|
+
defaultValue: "medium",
|
|
20
|
+
},
|
|
21
|
+
argTypes: {
|
|
22
|
+
label: {
|
|
23
|
+
description: "Visible label describing the field.",
|
|
24
|
+
control: "text",
|
|
25
|
+
table: { category: "Content" },
|
|
26
|
+
},
|
|
27
|
+
options: {
|
|
28
|
+
description: "Segment options ({ value, label, disabled? }[]).",
|
|
29
|
+
control: false,
|
|
30
|
+
table: { category: "Content" },
|
|
31
|
+
},
|
|
32
|
+
value: {
|
|
33
|
+
description: "Controlled selected value.",
|
|
34
|
+
control: false,
|
|
35
|
+
table: { category: "State" },
|
|
36
|
+
},
|
|
37
|
+
defaultValue: {
|
|
38
|
+
description: "Uncontrolled initial selected value.",
|
|
39
|
+
control: false,
|
|
40
|
+
table: { category: "State" },
|
|
41
|
+
},
|
|
42
|
+
size: {
|
|
43
|
+
description: "Size applied to every segment.",
|
|
44
|
+
control: { type: "radio" },
|
|
45
|
+
options: ["default", "sm", "lg"],
|
|
46
|
+
table: { category: "Appearance" },
|
|
47
|
+
},
|
|
48
|
+
disabled: {
|
|
49
|
+
description: "Disables every segment.",
|
|
50
|
+
control: "boolean",
|
|
51
|
+
table: { category: "State" },
|
|
52
|
+
},
|
|
53
|
+
onValueChange: {
|
|
54
|
+
description: 'Called when the selected value changes. Never called with "".',
|
|
55
|
+
control: false,
|
|
56
|
+
table: { category: "Behavior" },
|
|
57
|
+
},
|
|
58
|
+
className: {
|
|
59
|
+
description: "Additional CSS classes applied to the root element.",
|
|
60
|
+
control: "text",
|
|
61
|
+
table: { category: "Appearance" },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
} satisfies Meta<typeof SegmentedField>;
|
|
65
|
+
export default meta;
|
|
66
|
+
type Story = StoryObj<typeof meta>;
|
|
67
|
+
|
|
68
|
+
export const Default: Story = {};
|
|
69
|
+
|
|
70
|
+
export const Controlled: Story = {
|
|
71
|
+
render: () => {
|
|
72
|
+
// eslint-disable-next-line react-hooks/rules-of-hooks
|
|
73
|
+
const [value, setValue] = useState("medium");
|
|
74
|
+
return (
|
|
75
|
+
<div className="flex flex-col gap-2">
|
|
76
|
+
<SegmentedField
|
|
77
|
+
label="Priority"
|
|
78
|
+
options={PRIORITY_OPTIONS}
|
|
79
|
+
value={value}
|
|
80
|
+
onValueChange={setValue}
|
|
81
|
+
/>
|
|
82
|
+
<p className="text-body text-muted-foreground">Value: {value}</p>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Locks the whole reason this component exists: re-clicking the already
|
|
90
|
+
* active segment is a no-op — the value never becomes "" or undefined.
|
|
91
|
+
*/
|
|
92
|
+
export const ClickingActiveSegmentIsANoOp: Story = {
|
|
93
|
+
args: { defaultValue: "medium" },
|
|
94
|
+
play: async ({ canvas, userEvent }) => {
|
|
95
|
+
const mediumSegment = canvas.getByRole("radio", { name: "Medium" });
|
|
96
|
+
await expect(mediumSegment).toHaveAttribute("aria-checked", "true");
|
|
97
|
+
await userEvent.click(mediumSegment);
|
|
98
|
+
// Still checked — the empty-emission was swallowed, not applied.
|
|
99
|
+
await expect(mediumSegment).toHaveAttribute("aria-checked", "true");
|
|
100
|
+
await expect(canvas.getByRole("radio", { name: "Low" })).toHaveAttribute(
|
|
101
|
+
"aria-checked",
|
|
102
|
+
"false",
|
|
103
|
+
);
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
export const WithDisabledOption: Story = {
|
|
108
|
+
args: {
|
|
109
|
+
label: "Rollout stage",
|
|
110
|
+
options: [
|
|
111
|
+
{ value: "dev", label: "Dev" },
|
|
112
|
+
{ value: "staging", label: "Staging" },
|
|
113
|
+
{ value: "prod", label: "Prod", disabled: true },
|
|
114
|
+
],
|
|
115
|
+
defaultValue: "dev",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
export const Disabled: Story = {
|
|
120
|
+
args: { defaultValue: "medium", disabled: true },
|
|
121
|
+
};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { act, render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
4
|
+
import { SegmentedField } from "./segmented-field";
|
|
5
|
+
|
|
6
|
+
const OPTIONS = [
|
|
7
|
+
{ value: "low", label: "Low" },
|
|
8
|
+
{ value: "medium", label: "Medium" },
|
|
9
|
+
{ value: "high", label: "High" },
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
describe("SegmentedField", () => {
|
|
13
|
+
it("renders the label and every segment", () => {
|
|
14
|
+
render(<SegmentedField label="Priority" options={OPTIONS} defaultValue="low" />);
|
|
15
|
+
expect(screen.getByText("Priority")).toBeInTheDocument();
|
|
16
|
+
expect(screen.getByRole("radio", { name: "Low" })).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByRole("radio", { name: "Medium" })).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByRole("radio", { name: "High" })).toBeInTheDocument();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("always reports exactly one selected value (uncontrolled)", async () => {
|
|
22
|
+
const onValueChange = vi.fn();
|
|
23
|
+
render(
|
|
24
|
+
<SegmentedField
|
|
25
|
+
label="Priority"
|
|
26
|
+
options={OPTIONS}
|
|
27
|
+
defaultValue="low"
|
|
28
|
+
onValueChange={onValueChange}
|
|
29
|
+
/>,
|
|
30
|
+
);
|
|
31
|
+
await userEvent.click(screen.getByRole("radio", { name: "High" }));
|
|
32
|
+
expect(onValueChange).toHaveBeenCalledWith("high");
|
|
33
|
+
expect(screen.getByRole("radio", { name: "High" })).toHaveAttribute("aria-checked", "true");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("clicking the already-active segment is a no-op (never emits '')", async () => {
|
|
37
|
+
const onValueChange = vi.fn();
|
|
38
|
+
render(
|
|
39
|
+
<SegmentedField
|
|
40
|
+
label="Priority"
|
|
41
|
+
options={OPTIONS}
|
|
42
|
+
value="medium"
|
|
43
|
+
onValueChange={onValueChange}
|
|
44
|
+
/>,
|
|
45
|
+
);
|
|
46
|
+
const mediumSegment = screen.getByRole("radio", { name: "Medium" });
|
|
47
|
+
await userEvent.click(mediumSegment);
|
|
48
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
49
|
+
expect(mediumSegment).toHaveAttribute("aria-checked", "true");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("never calls onValueChange with an empty string", async () => {
|
|
53
|
+
const onValueChange = vi.fn();
|
|
54
|
+
render(
|
|
55
|
+
<SegmentedField
|
|
56
|
+
label="Priority"
|
|
57
|
+
options={OPTIONS}
|
|
58
|
+
defaultValue="low"
|
|
59
|
+
onValueChange={onValueChange}
|
|
60
|
+
/>,
|
|
61
|
+
);
|
|
62
|
+
await userEvent.click(screen.getByRole("radio", { name: "Low" }));
|
|
63
|
+
await userEvent.click(screen.getByRole("radio", { name: "Low" }));
|
|
64
|
+
for (const call of onValueChange.mock.calls) {
|
|
65
|
+
expect(call[0]).not.toBe("");
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("moves selection with arrow-key navigation (selection follows focus)", async () => {
|
|
70
|
+
const onValueChange = vi.fn();
|
|
71
|
+
render(
|
|
72
|
+
<SegmentedField
|
|
73
|
+
label="Priority"
|
|
74
|
+
options={OPTIONS}
|
|
75
|
+
defaultValue="low"
|
|
76
|
+
onValueChange={onValueChange}
|
|
77
|
+
/>,
|
|
78
|
+
);
|
|
79
|
+
const lowSegment = screen.getByRole("radio", { name: "Low" });
|
|
80
|
+
act(() => {
|
|
81
|
+
lowSegment.focus();
|
|
82
|
+
});
|
|
83
|
+
await userEvent.keyboard("{ArrowRight}");
|
|
84
|
+
expect(onValueChange).toHaveBeenCalledWith("medium");
|
|
85
|
+
expect(screen.getByRole("radio", { name: "Medium" })).toHaveAttribute("aria-checked", "true");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("clicking the label focuses the SELECTED segment without changing the value", async () => {
|
|
89
|
+
const onValueChange = vi.fn();
|
|
90
|
+
render(
|
|
91
|
+
<SegmentedField
|
|
92
|
+
label="Priority"
|
|
93
|
+
options={OPTIONS}
|
|
94
|
+
defaultValue="medium"
|
|
95
|
+
onValueChange={onValueChange}
|
|
96
|
+
/>,
|
|
97
|
+
);
|
|
98
|
+
await userEvent.click(screen.getByText("Priority"));
|
|
99
|
+
// A `<label>` moves focus into its field — it never mutates it.
|
|
100
|
+
expect(screen.getByRole("radio", { name: "Medium" })).toHaveFocus();
|
|
101
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
102
|
+
expect(screen.getByRole("radio", { name: "Medium" })).toHaveAttribute("aria-checked", "true");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("tabbing into a group with nothing selected does not auto-select", async () => {
|
|
106
|
+
const onValueChange = vi.fn();
|
|
107
|
+
render(
|
|
108
|
+
<>
|
|
109
|
+
<button>before</button>
|
|
110
|
+
<SegmentedField label="Priority" options={OPTIONS} onValueChange={onValueChange} />
|
|
111
|
+
</>,
|
|
112
|
+
);
|
|
113
|
+
screen.getByRole("button", { name: "before" }).focus();
|
|
114
|
+
await userEvent.tab();
|
|
115
|
+
expect(onValueChange).not.toHaveBeenCalled();
|
|
116
|
+
expect(screen.queryByRole("radio", { checked: true })).not.toBeInTheDocument();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("stays controlled for Radix even when nothing is selected yet", async () => {
|
|
120
|
+
const warn = vi.spyOn(console, "error").mockImplementation(() => {});
|
|
121
|
+
render(<SegmentedField label="Priority" options={OPTIONS} />);
|
|
122
|
+
await userEvent.click(screen.getByRole("radio", { name: "High" }));
|
|
123
|
+
// A `value` that starts `undefined` and becomes a string is a mode flip;
|
|
124
|
+
// Radix reports it on console.error.
|
|
125
|
+
expect(warn.mock.calls.flat().join(" ")).not.toMatch(/uncontrolled to controlled/i);
|
|
126
|
+
warn.mockRestore();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("disables every segment when disabled is set", () => {
|
|
130
|
+
render(<SegmentedField label="Priority" options={OPTIONS} defaultValue="low" disabled />);
|
|
131
|
+
expect(screen.getByRole("radio", { name: "Low" })).toBeDisabled();
|
|
132
|
+
expect(screen.getByRole("radio", { name: "Medium" })).toBeDisabled();
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("disables just the option marked disabled", () => {
|
|
136
|
+
render(
|
|
137
|
+
<SegmentedField
|
|
138
|
+
label="Stage"
|
|
139
|
+
options={[
|
|
140
|
+
{ value: "dev", label: "Dev" },
|
|
141
|
+
{ value: "prod", label: "Prod", disabled: true },
|
|
142
|
+
]}
|
|
143
|
+
defaultValue="dev"
|
|
144
|
+
/>,
|
|
145
|
+
);
|
|
146
|
+
expect(screen.getByRole("radio", { name: "Prod" })).toBeDisabled();
|
|
147
|
+
expect(screen.getByRole("radio", { name: "Dev" })).not.toBeDisabled();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("supports the controlled mode via value/onValueChange", async () => {
|
|
151
|
+
const onValueChange = vi.fn();
|
|
152
|
+
render(
|
|
153
|
+
<SegmentedField
|
|
154
|
+
label="Priority"
|
|
155
|
+
options={OPTIONS}
|
|
156
|
+
value="low"
|
|
157
|
+
onValueChange={onValueChange}
|
|
158
|
+
/>,
|
|
159
|
+
);
|
|
160
|
+
await userEvent.click(screen.getByRole("radio", { name: "High" }));
|
|
161
|
+
expect(onValueChange).toHaveBeenCalledWith("high");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
forwardRef,
|
|
5
|
+
useCallback,
|
|
6
|
+
useId,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type FocusEvent as ReactFocusEvent,
|
|
10
|
+
type HTMLAttributes,
|
|
11
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
12
|
+
type PointerEvent as ReactPointerEvent,
|
|
13
|
+
type ReactNode,
|
|
14
|
+
} from "react";
|
|
15
|
+
import { cn } from "../../lib/cn";
|
|
16
|
+
import { Label } from "../label";
|
|
17
|
+
import { ToggleGroup, ToggleGroupItem } from "../toggle-group";
|
|
18
|
+
|
|
19
|
+
/** Keys whose focus move must also move the selection (WAI-ARIA radiogroup). */
|
|
20
|
+
const NAV_KEYS = new Set(["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"]);
|
|
21
|
+
|
|
22
|
+
export interface SegmentedFieldOption {
|
|
23
|
+
/** The option's value, reported to `onValueChange`. */
|
|
24
|
+
value: string;
|
|
25
|
+
/** Visible content for the segment. */
|
|
26
|
+
label: ReactNode;
|
|
27
|
+
/** Disables just this segment. */
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SegmentedFieldProps extends Omit<
|
|
32
|
+
HTMLAttributes<HTMLDivElement>,
|
|
33
|
+
"onChange" | "defaultValue"
|
|
34
|
+
> {
|
|
35
|
+
/** Visible label describing the field. */
|
|
36
|
+
label: ReactNode;
|
|
37
|
+
/**
|
|
38
|
+
* Segment options. Omit and render `ToggleGroupItem` children directly for
|
|
39
|
+
* the compound-component escape hatch (component-api.md) when a plain
|
|
40
|
+
* `{ value, label }` shape isn't expressive enough.
|
|
41
|
+
*/
|
|
42
|
+
options?: SegmentedFieldOption[];
|
|
43
|
+
/** Controlled selected value. */
|
|
44
|
+
value?: string;
|
|
45
|
+
/** Uncontrolled initial selected value. */
|
|
46
|
+
defaultValue?: string;
|
|
47
|
+
/**
|
|
48
|
+
* Called when the selected value changes. Never called with `""` —
|
|
49
|
+
* re-clicking the active segment (Radix `ToggleGroup`'s `type="single"`
|
|
50
|
+
* empty-emission on re-click) is swallowed as a no-op instead of clearing
|
|
51
|
+
* the field.
|
|
52
|
+
*/
|
|
53
|
+
onValueChange?: (value: string) => void;
|
|
54
|
+
/** Size applied to every segment. Defaults to "sm", matching the compact segmented look. */
|
|
55
|
+
size?: "default" | "sm" | "lg";
|
|
56
|
+
/** Disables every segment. */
|
|
57
|
+
disabled?: boolean;
|
|
58
|
+
/** `ToggleGroupItem` children — the compound escape hatch (used instead of `options`). */
|
|
59
|
+
children?: ReactNode;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* SegmentedField — a labelled segmented control with STICKY selection.
|
|
64
|
+
*
|
|
65
|
+
* Composes the existing `Label` + `ToggleGroup`/`ToggleGroupItem`
|
|
66
|
+
* (`variant="segmented"`) — no new visual style. The whole reason this
|
|
67
|
+
* component exists: Radix's `ToggleGroup` in `type="single"` mode emits `""`
|
|
68
|
+
* when the already-active segment is re-clicked, silently clearing a field
|
|
69
|
+
* that must always hold exactly one value. `SegmentedField` swallows that
|
|
70
|
+
* emission so re-clicking the active segment is a true no-op.
|
|
71
|
+
*
|
|
72
|
+
* Per the WAI-ARIA radiogroup pattern (each segment renders `role="radio"`
|
|
73
|
+
* for `type="single"`), ARROW-KEY navigation also SELECTS the newly focused
|
|
74
|
+
* segment (selection follows focus), matching native radio-button behavior —
|
|
75
|
+
* Radix's roving-tabindex only moves focus, so this component adds that
|
|
76
|
+
* activation on top of it.
|
|
77
|
+
*
|
|
78
|
+
* Selection follows focus ONLY for arrow/Home/End keys, never for arbitrary
|
|
79
|
+
* focus. A bare `onFocus` commit would make three non-navigation paths mutate
|
|
80
|
+
* the field: clicking the `<Label>` (which moves focus into the group),
|
|
81
|
+
* tabbing into a group with nothing selected yet, and any programmatic
|
|
82
|
+
* `.focus()` a consumer performs. The keyboard intent is recorded on the
|
|
83
|
+
* wrapper in the CAPTURE phase (it must land before Radix's own roving-focus
|
|
84
|
+
* keydown handler runs) and is cleared by the focus it explains, by a pointer
|
|
85
|
+
* press, by any non-navigation key, or by focus leaving the field — so it can
|
|
86
|
+
* never leak into a later, unrelated focus.
|
|
87
|
+
*/
|
|
88
|
+
export const SegmentedField = forwardRef<HTMLDivElement, SegmentedFieldProps>(
|
|
89
|
+
function SegmentedField(
|
|
90
|
+
{
|
|
91
|
+
label,
|
|
92
|
+
options,
|
|
93
|
+
value: valueProp,
|
|
94
|
+
defaultValue,
|
|
95
|
+
onValueChange,
|
|
96
|
+
size = "sm",
|
|
97
|
+
disabled,
|
|
98
|
+
id,
|
|
99
|
+
className,
|
|
100
|
+
children,
|
|
101
|
+
...props
|
|
102
|
+
},
|
|
103
|
+
ref,
|
|
104
|
+
) {
|
|
105
|
+
const generatedId = useId();
|
|
106
|
+
const labelId = `${id ?? generatedId}-label`;
|
|
107
|
+
const groupRef = useRef<HTMLDivElement>(null);
|
|
108
|
+
|
|
109
|
+
const isControlled = valueProp !== undefined;
|
|
110
|
+
const [internalValue, setInternalValue] = useState(defaultValue);
|
|
111
|
+
const currentValue = isControlled ? valueProp : internalValue;
|
|
112
|
+
|
|
113
|
+
// Synced every render (mirrors the ref-during-render idiom in
|
|
114
|
+
// NumberInput) so a same-event double-dispatch (focus-follows-selection
|
|
115
|
+
// AND the click's own onPressedChange) commits at most once.
|
|
116
|
+
const lastCommittedRef = useRef(currentValue);
|
|
117
|
+
lastCommittedRef.current = currentValue;
|
|
118
|
+
|
|
119
|
+
const commit = useCallback(
|
|
120
|
+
(next: string) => {
|
|
121
|
+
// Radix emits "" when the active segment is re-clicked — swallow it.
|
|
122
|
+
if (next === "" || next === lastCommittedRef.current) return;
|
|
123
|
+
lastCommittedRef.current = next;
|
|
124
|
+
if (!isControlled) setInternalValue(next);
|
|
125
|
+
onValueChange?.(next);
|
|
126
|
+
},
|
|
127
|
+
[isControlled, onValueChange],
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
// Armed by an arrow/Home/End key, DISARMED by anything else — a pointer
|
|
131
|
+
// press, any other key, or focus leaving the field. It is deliberately not
|
|
132
|
+
// a timer: `@radix-ui/react-roving-focus` moves focus in a
|
|
133
|
+
// `setTimeout(() => focusFirst(...))`, so a microtask/`setTimeout` reset
|
|
134
|
+
// queued from the capture phase would always fire first and the arm would
|
|
135
|
+
// be gone before the focus it exists to classify.
|
|
136
|
+
const keyboardNavRef = useRef(false);
|
|
137
|
+
|
|
138
|
+
const handleKeyDownCapture = useCallback(
|
|
139
|
+
(event: ReactKeyboardEvent<HTMLDivElement>) => {
|
|
140
|
+
keyboardNavRef.current = NAV_KEYS.has(event.key);
|
|
141
|
+
props.onKeyDownCapture?.(event);
|
|
142
|
+
},
|
|
143
|
+
[props],
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const handlePointerDownCapture = useCallback(
|
|
147
|
+
(event: ReactPointerEvent<HTMLDivElement>) => {
|
|
148
|
+
keyboardNavRef.current = false;
|
|
149
|
+
props.onPointerDownCapture?.(event);
|
|
150
|
+
},
|
|
151
|
+
[props],
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const handleBlurCapture = useCallback(
|
|
155
|
+
(event: ReactFocusEvent<HTMLDivElement>) => {
|
|
156
|
+
// Focus moving between segments keeps the arm; leaving the field drops
|
|
157
|
+
// it, so a stale arm can never fire on a later tab-in.
|
|
158
|
+
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
|
|
159
|
+
keyboardNavRef.current = false;
|
|
160
|
+
}
|
|
161
|
+
props.onBlurCapture?.(event);
|
|
162
|
+
},
|
|
163
|
+
[props],
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const handleItemFocus = useCallback(
|
|
167
|
+
(next: string) => {
|
|
168
|
+
if (!keyboardNavRef.current) return;
|
|
169
|
+
keyboardNavRef.current = false;
|
|
170
|
+
commit(next);
|
|
171
|
+
},
|
|
172
|
+
[commit],
|
|
173
|
+
);
|
|
174
|
+
|
|
175
|
+
// Match a native `<label>`: move focus into the field without changing it.
|
|
176
|
+
// Focusing the SELECTED segment (not blindly the first) keeps the visible
|
|
177
|
+
// selection and the focus ring in agreement.
|
|
178
|
+
const handleLabelClick = useCallback(() => {
|
|
179
|
+
const group = groupRef.current;
|
|
180
|
+
if (!group) return;
|
|
181
|
+
const selected = group.querySelector<HTMLButtonElement>(
|
|
182
|
+
'[aria-checked="true"]:not(:disabled),[data-state="on"]:not(:disabled)',
|
|
183
|
+
);
|
|
184
|
+
(selected ?? group.querySelector<HTMLButtonElement>("button:not(:disabled)"))?.focus();
|
|
185
|
+
}, []);
|
|
186
|
+
|
|
187
|
+
return (
|
|
188
|
+
<div
|
|
189
|
+
ref={ref}
|
|
190
|
+
data-slot="segmented-field"
|
|
191
|
+
className={cn("flex flex-col items-start gap-1.5", className)}
|
|
192
|
+
{...props}
|
|
193
|
+
// After the spread: these three are load-bearing (they classify a
|
|
194
|
+
// focus as arrow-navigation or not) and each re-invokes the consumer's
|
|
195
|
+
// handler, so composing beats being silently replaced.
|
|
196
|
+
onKeyDownCapture={handleKeyDownCapture}
|
|
197
|
+
onPointerDownCapture={handlePointerDownCapture}
|
|
198
|
+
onBlurCapture={handleBlurCapture}
|
|
199
|
+
>
|
|
200
|
+
<Label id={labelId} data-slot="segmented-field-label" onClick={handleLabelClick}>
|
|
201
|
+
{label}
|
|
202
|
+
</Label>
|
|
203
|
+
<ToggleGroup
|
|
204
|
+
ref={groupRef}
|
|
205
|
+
type="single"
|
|
206
|
+
variant="segmented"
|
|
207
|
+
size={size}
|
|
208
|
+
// Always a string, never `undefined` — an unselected field would
|
|
209
|
+
// otherwise start UNcontrolled and flip to controlled on the first
|
|
210
|
+
// selection (React/Radix warn, and the mode flip is exactly what
|
|
211
|
+
// component-api.md forbids). `""` is Radix's "nothing selected".
|
|
212
|
+
value={currentValue ?? ""}
|
|
213
|
+
onValueChange={commit}
|
|
214
|
+
disabled={disabled}
|
|
215
|
+
aria-labelledby={labelId}
|
|
216
|
+
data-slot="segmented-field-options"
|
|
217
|
+
>
|
|
218
|
+
{options
|
|
219
|
+
? options.map((option) => (
|
|
220
|
+
<ToggleGroupItem
|
|
221
|
+
key={option.value}
|
|
222
|
+
data-slot="segmented-field-segment"
|
|
223
|
+
value={option.value}
|
|
224
|
+
disabled={option.disabled}
|
|
225
|
+
onFocus={() => handleItemFocus(option.value)}
|
|
226
|
+
>
|
|
227
|
+
{option.label}
|
|
228
|
+
</ToggleGroupItem>
|
|
229
|
+
))
|
|
230
|
+
: children}
|
|
231
|
+
</ToggleGroup>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
},
|
|
235
|
+
);
|