@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,1102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
forwardRef,
|
|
6
|
+
use,
|
|
7
|
+
useCallback,
|
|
8
|
+
useEffect,
|
|
9
|
+
useId,
|
|
10
|
+
useLayoutEffect,
|
|
11
|
+
useMemo,
|
|
12
|
+
useRef,
|
|
13
|
+
useState,
|
|
14
|
+
type CSSProperties,
|
|
15
|
+
type HTMLAttributes,
|
|
16
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
type TextareaHTMLAttributes,
|
|
19
|
+
} from "react";
|
|
20
|
+
import { Slot } from "@radix-ui/react-slot";
|
|
21
|
+
import { cn } from "../../lib/cn";
|
|
22
|
+
import { mergeRefs } from "../../lib/merge-refs";
|
|
23
|
+
import { useLocale } from "../locale-provider";
|
|
24
|
+
import { Popover, PopoverAnchor, PopoverContent } from "../popover";
|
|
25
|
+
import { Textarea } from "../textarea";
|
|
26
|
+
import {
|
|
27
|
+
defaultMentionFilter,
|
|
28
|
+
insertMention,
|
|
29
|
+
mentionAt,
|
|
30
|
+
mentionEnd,
|
|
31
|
+
remapMentions,
|
|
32
|
+
type Mention,
|
|
33
|
+
type MentionOption,
|
|
34
|
+
type MentionValue,
|
|
35
|
+
} from "./mention-value";
|
|
36
|
+
|
|
37
|
+
export type { Mention, MentionOption, MentionValue };
|
|
38
|
+
|
|
39
|
+
const EMPTY_VALUE: MentionValue = { text: "", mentions: [] };
|
|
40
|
+
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// Context — state / actions / meta (component-api.md provider-injection shape)
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
export interface MentionInputContextValue {
|
|
46
|
+
state: {
|
|
47
|
+
value: MentionValue;
|
|
48
|
+
/** The in-progress query WITHOUT the trigger char; `null` when closed. */
|
|
49
|
+
query: string | null;
|
|
50
|
+
open: boolean;
|
|
51
|
+
/** Index into `filtered`; `-1` when nothing is highlighted. */
|
|
52
|
+
activeIndex: number;
|
|
53
|
+
/** DOM id of the highlighted option; `undefined` when nothing is. */
|
|
54
|
+
activeId?: string;
|
|
55
|
+
filtered: MentionOption[];
|
|
56
|
+
};
|
|
57
|
+
actions: {
|
|
58
|
+
insert: (option: MentionOption) => void;
|
|
59
|
+
close: () => void;
|
|
60
|
+
setActiveIndex: (index: number) => void;
|
|
61
|
+
};
|
|
62
|
+
meta: {
|
|
63
|
+
trigger: string;
|
|
64
|
+
listId: string;
|
|
65
|
+
/**
|
|
66
|
+
* The id `MentionInputTextarea` puts on its element. Advisory only — with
|
|
67
|
+
* `asChild`, Radix `Slot` lets a consumer-supplied `id` on the child win
|
|
68
|
+
* (child props are merged OVER slot props), so the component itself never
|
|
69
|
+
* resolves the field through this id; it holds a real ref.
|
|
70
|
+
*/
|
|
71
|
+
textareaId: string;
|
|
72
|
+
optionId: (index: number) => string;
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const MentionInputContext = createContext<MentionInputContextValue | null>(null);
|
|
77
|
+
|
|
78
|
+
/** Read the surrounding `MentionInput`'s state, actions and ids. */
|
|
79
|
+
export function useMentionInput(): MentionInputContextValue {
|
|
80
|
+
const ctx = use(MentionInputContext);
|
|
81
|
+
if (!ctx) {
|
|
82
|
+
throw new Error("useMentionInput must be used inside a <MentionInput>.");
|
|
83
|
+
}
|
|
84
|
+
return ctx;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
// Query detection
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/** A trigger only starts a query at the start of a word — never inside one. */
|
|
92
|
+
function isWordBoundary(text: string, index: number): boolean {
|
|
93
|
+
if (index === 0) return true;
|
|
94
|
+
const previous = text[index - 1] ?? "";
|
|
95
|
+
return /\s/.test(previous) || previous === "(" || previous === "[";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Is the caret currently sitting in a `trigger + query` run?
|
|
100
|
+
*
|
|
101
|
+
* Derived from the committed text + caret rather than intercepted at keydown,
|
|
102
|
+
* on purpose: a keydown-driven trigger would have to `preventDefault()` every
|
|
103
|
+
* printable character and re-insert it by hand, which is exactly what destroys
|
|
104
|
+
* IME composition, native undo, spellcheck and paste — the things a real
|
|
105
|
+
* `<textarea>` gives us for free. Deriving instead means the popup opens
|
|
106
|
+
* identically whether the trigger arrived by typing, pasting, an IME commit or
|
|
107
|
+
* a mouse click that moved the caret back into a half-typed query.
|
|
108
|
+
*/
|
|
109
|
+
function findQuery(
|
|
110
|
+
value: MentionValue,
|
|
111
|
+
caret: number,
|
|
112
|
+
trigger: string,
|
|
113
|
+
): { start: number; query: string } | null {
|
|
114
|
+
const { text } = value;
|
|
115
|
+
if (caret < trigger.length) return null;
|
|
116
|
+
const start = text.lastIndexOf(trigger, caret - trigger.length);
|
|
117
|
+
if (start < 0) return null;
|
|
118
|
+
|
|
119
|
+
const query = text.slice(start + trigger.length, caret);
|
|
120
|
+
if (/\s/.test(query)) return null;
|
|
121
|
+
if (!isWordBoundary(text, start)) return null;
|
|
122
|
+
// The trigger char of an ALREADY-inserted mention must not re-open the popup.
|
|
123
|
+
if (mentionAt(value, start, trigger)) return null;
|
|
124
|
+
|
|
125
|
+
return { start, query };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Next enabled index in `list`, wrapping; `-1` when none is selectable. */
|
|
129
|
+
function step(list: MentionOption[], from: number, direction: 1 | -1): number {
|
|
130
|
+
const count = list.length;
|
|
131
|
+
if (count === 0) return -1;
|
|
132
|
+
for (let i = 1; i <= count; i++) {
|
|
133
|
+
const index = (((from + direction * i) % count) + count) % count;
|
|
134
|
+
if (!list[index]?.disabled) return index;
|
|
135
|
+
}
|
|
136
|
+
return -1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function firstEnabled(list: MentionOption[]): number {
|
|
140
|
+
const index = list.findIndex((o) => !o.disabled);
|
|
141
|
+
return index;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function lastEnabled(list: MentionOption[]): number {
|
|
145
|
+
for (let i = list.length - 1; i >= 0; i--) {
|
|
146
|
+
if (!list[i]?.disabled) return i;
|
|
147
|
+
}
|
|
148
|
+
return -1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Caret / overlay mirror
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Computed properties the mirror has to copy verbatim for its glyph metrics to
|
|
157
|
+
* land on the same pixels as the textarea's. Anything that changes where a
|
|
158
|
+
* character starts belongs here.
|
|
159
|
+
*/
|
|
160
|
+
const MIRRORED_PROPERTIES = [
|
|
161
|
+
"boxSizing",
|
|
162
|
+
"paddingTop",
|
|
163
|
+
"paddingRight",
|
|
164
|
+
"paddingBottom",
|
|
165
|
+
"paddingLeft",
|
|
166
|
+
"borderTopWidth",
|
|
167
|
+
"borderRightWidth",
|
|
168
|
+
"borderBottomWidth",
|
|
169
|
+
"borderLeftWidth",
|
|
170
|
+
"fontFamily",
|
|
171
|
+
"fontSize",
|
|
172
|
+
"fontWeight",
|
|
173
|
+
"fontStyle",
|
|
174
|
+
"fontVariant",
|
|
175
|
+
"fontStretch",
|
|
176
|
+
"fontFeatureSettings",
|
|
177
|
+
"fontVariationSettings",
|
|
178
|
+
"letterSpacing",
|
|
179
|
+
"lineHeight",
|
|
180
|
+
"textTransform",
|
|
181
|
+
"textIndent",
|
|
182
|
+
"wordSpacing",
|
|
183
|
+
"textAlign",
|
|
184
|
+
"direction",
|
|
185
|
+
"tabSize",
|
|
186
|
+
// Wrapping, copied rather than assumed: forcing `word-break: break-word` when
|
|
187
|
+
// the field says `normal` breaks long words one line early in the mirror.
|
|
188
|
+
"whiteSpace",
|
|
189
|
+
"overflowWrap",
|
|
190
|
+
"wordBreak",
|
|
191
|
+
] as const satisfies readonly (keyof CSSStyleDeclaration & string)[];
|
|
192
|
+
|
|
193
|
+
function readMirrorStyle(textarea: HTMLTextAreaElement): CSSProperties {
|
|
194
|
+
const computed = getComputedStyle(textarea);
|
|
195
|
+
const style: Record<string, string | number> = {};
|
|
196
|
+
for (const property of MIRRORED_PROPERTIES) {
|
|
197
|
+
style[property] = computed[property] as string;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const borderX =
|
|
201
|
+
Number.parseFloat(computed.borderLeftWidth || "0") +
|
|
202
|
+
Number.parseFloat(computed.borderRightWidth || "0");
|
|
203
|
+
// Whatever the box is wider than its content area, minus the borders, is the
|
|
204
|
+
// vertical scrollbar. Fold it into the padding or every wrapped line in the
|
|
205
|
+
// mirror breaks a few characters later than it does in the field.
|
|
206
|
+
const scrollbar = Math.max(0, textarea.offsetWidth - textarea.clientWidth - borderX);
|
|
207
|
+
|
|
208
|
+
return {
|
|
209
|
+
...(style as CSSProperties),
|
|
210
|
+
position: "absolute",
|
|
211
|
+
left: textarea.offsetLeft,
|
|
212
|
+
top: textarea.offsetTop,
|
|
213
|
+
width: textarea.offsetWidth,
|
|
214
|
+
height: textarea.offsetHeight,
|
|
215
|
+
paddingRight: Number.parseFloat(computed.paddingRight || "0") + scrollbar,
|
|
216
|
+
borderStyle: "solid",
|
|
217
|
+
borderColor: "transparent",
|
|
218
|
+
// A textarea already computes `pre-wrap`; the fallback only covers a host
|
|
219
|
+
// that reset it, and an environment (jsdom) that resolves nothing.
|
|
220
|
+
whiteSpace: computed.whiteSpace || "pre-wrap",
|
|
221
|
+
overflow: "hidden",
|
|
222
|
+
margin: 0,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** Split the text into plain runs and mention runs, in document order. */
|
|
227
|
+
function buildRuns(
|
|
228
|
+
value: MentionValue,
|
|
229
|
+
trigger: string,
|
|
230
|
+
): Array<{ key: string; text: string; mention?: Mention }> {
|
|
231
|
+
const runs: Array<{ key: string; text: string; mention?: Mention }> = [];
|
|
232
|
+
const ordered = [...value.mentions].sort((a, b) => a.start - b.start);
|
|
233
|
+
let cursor = 0;
|
|
234
|
+
for (const mention of ordered) {
|
|
235
|
+
if (mention.start > cursor) {
|
|
236
|
+
runs.push({ key: `t${cursor}`, text: value.text.slice(cursor, mention.start) });
|
|
237
|
+
}
|
|
238
|
+
const end = mentionEnd(mention, trigger);
|
|
239
|
+
runs.push({ key: `m${mention.start}`, text: value.text.slice(mention.start, end), mention });
|
|
240
|
+
cursor = end;
|
|
241
|
+
}
|
|
242
|
+
if (cursor < value.text.length) {
|
|
243
|
+
runs.push({ key: `t${cursor}`, text: value.text.slice(cursor) });
|
|
244
|
+
}
|
|
245
|
+
return runs;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// MentionInput (root + provider + Popover)
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
export interface MentionInputProps extends Omit<
|
|
253
|
+
HTMLAttributes<HTMLDivElement>,
|
|
254
|
+
"onChange" | "defaultValue"
|
|
255
|
+
> {
|
|
256
|
+
/** The roster the popup offers. */
|
|
257
|
+
options: MentionOption[];
|
|
258
|
+
/** Controlled value. */
|
|
259
|
+
value?: MentionValue;
|
|
260
|
+
/** Uncontrolled initial value. */
|
|
261
|
+
defaultValue?: MentionValue;
|
|
262
|
+
/** Called with the whole value whenever text or mentions change. */
|
|
263
|
+
onValueChange?: (value: MentionValue) => void;
|
|
264
|
+
/** Trigger character. Default `"@"`. */
|
|
265
|
+
trigger?: string;
|
|
266
|
+
/**
|
|
267
|
+
* Which options survive the current query. Defaults to
|
|
268
|
+
* `defaultMentionFilter`; pass `() => true` when the roster is fetched
|
|
269
|
+
* app-side from `onQueryChange`.
|
|
270
|
+
*/
|
|
271
|
+
filter?: (option: MentionOption, query: string) => boolean;
|
|
272
|
+
/** The query changed. `null` means the popup closed. */
|
|
273
|
+
onQueryChange?: (query: string | null) => void;
|
|
274
|
+
/** Controlled popup state. */
|
|
275
|
+
open?: boolean;
|
|
276
|
+
/** Called when the popup wants to open or close. */
|
|
277
|
+
onOpenChange?: (open: boolean) => void;
|
|
278
|
+
children: ReactNode;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* MentionInput — an `@`-mention text field built on a real `<textarea>`.
|
|
283
|
+
*
|
|
284
|
+
* The field stays a native textarea (so IME, paste, undo, spellcheck, mobile
|
|
285
|
+
* keyboards and `FormData` all keep working) and mentions are tracked as
|
|
286
|
+
* offsets beside it. The suggestion popup is this component's own listbox
|
|
287
|
+
* driven by `aria-activedescendant`, so **focus never leaves the textarea** —
|
|
288
|
+
* a popup that took focus would take the caret with it.
|
|
289
|
+
*/
|
|
290
|
+
export const MentionInput = forwardRef<HTMLDivElement, MentionInputProps>(function MentionInput(
|
|
291
|
+
{
|
|
292
|
+
options,
|
|
293
|
+
value: valueProp,
|
|
294
|
+
defaultValue,
|
|
295
|
+
onValueChange,
|
|
296
|
+
trigger = "@",
|
|
297
|
+
filter = defaultMentionFilter,
|
|
298
|
+
onQueryChange,
|
|
299
|
+
open: openProp,
|
|
300
|
+
onOpenChange,
|
|
301
|
+
className,
|
|
302
|
+
children,
|
|
303
|
+
...props
|
|
304
|
+
},
|
|
305
|
+
ref,
|
|
306
|
+
) {
|
|
307
|
+
const uid = useId();
|
|
308
|
+
const listId = `${uid}-listbox`;
|
|
309
|
+
const textareaId = `${uid}-textarea`;
|
|
310
|
+
const optionId = useCallback((index: number) => `${uid}-option-${index}`, [uid]);
|
|
311
|
+
|
|
312
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
313
|
+
const mergedRootRef = useMemo(() => mergeRefs<HTMLDivElement>(ref, rootRef), [ref]);
|
|
314
|
+
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
|
315
|
+
|
|
316
|
+
// ------------------------------------------------------------------
|
|
317
|
+
// Controlled / uncontrolled value
|
|
318
|
+
// ------------------------------------------------------------------
|
|
319
|
+
const isControlled = valueProp !== undefined;
|
|
320
|
+
const [internalValue, setInternalValue] = useState<MentionValue>(defaultValue ?? EMPTY_VALUE);
|
|
321
|
+
const value = isControlled ? valueProp : internalValue;
|
|
322
|
+
|
|
323
|
+
const commit = useCallback(
|
|
324
|
+
(next: MentionValue) => {
|
|
325
|
+
if (!isControlled) setInternalValue(next);
|
|
326
|
+
onValueChange?.(next);
|
|
327
|
+
},
|
|
328
|
+
[isControlled, onValueChange],
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// ------------------------------------------------------------------
|
|
332
|
+
// Caret
|
|
333
|
+
// ------------------------------------------------------------------
|
|
334
|
+
const [caret, setCaret] = useState(() => (defaultValue?.text ?? "").length);
|
|
335
|
+
const pendingCaretRef = useRef<number | null>(null);
|
|
336
|
+
|
|
337
|
+
const applyCaret = useCallback((position: number) => {
|
|
338
|
+
pendingCaretRef.current = position;
|
|
339
|
+
setCaret(position);
|
|
340
|
+
// Pure caret moves change no value, so React may skip the re-render that
|
|
341
|
+
// would flush `pendingCaretRef` — set it here as well. Value changes still
|
|
342
|
+
// need the post-commit pass below, because React writes the new `value`
|
|
343
|
+
// into the DOM after this point and that resets the selection.
|
|
344
|
+
textareaRef.current?.setSelectionRange(position, position);
|
|
345
|
+
}, []);
|
|
346
|
+
|
|
347
|
+
useLayoutEffect(() => {
|
|
348
|
+
const position = pendingCaretRef.current;
|
|
349
|
+
if (position === null) return;
|
|
350
|
+
pendingCaretRef.current = null;
|
|
351
|
+
textareaRef.current?.setSelectionRange(position, position);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// ------------------------------------------------------------------
|
|
355
|
+
// Query / open state
|
|
356
|
+
// ------------------------------------------------------------------
|
|
357
|
+
const queryInfo = useMemo(() => findQuery(value, caret, trigger), [value, caret, trigger]);
|
|
358
|
+
|
|
359
|
+
// Escape (and an outside dismissal) suppress the popup for the run it was
|
|
360
|
+
// dismissed on, without touching the text — the query is still there, the
|
|
361
|
+
// user just said they are done with the roster for now.
|
|
362
|
+
const [dismissedStart, setDismissedStart] = useState<number | null>(null);
|
|
363
|
+
useEffect(() => {
|
|
364
|
+
if (queryInfo === null && dismissedStart !== null) setDismissedStart(null);
|
|
365
|
+
}, [queryInfo, dismissedStart]);
|
|
366
|
+
|
|
367
|
+
const derivedOpen = queryInfo !== null && dismissedStart !== queryInfo.start;
|
|
368
|
+
const open = openProp ?? derivedOpen;
|
|
369
|
+
const query = open && queryInfo ? queryInfo.query : null;
|
|
370
|
+
|
|
371
|
+
const setOpen = useCallback(
|
|
372
|
+
(next: boolean) => {
|
|
373
|
+
onOpenChange?.(next);
|
|
374
|
+
if (!next) setDismissedStart(queryInfo?.start ?? null);
|
|
375
|
+
},
|
|
376
|
+
[onOpenChange, queryInfo],
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
const onQueryChangeRef = useRef(onQueryChange);
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
onQueryChangeRef.current = onQueryChange;
|
|
382
|
+
}, [onQueryChange]);
|
|
383
|
+
useEffect(() => {
|
|
384
|
+
onQueryChangeRef.current?.(query);
|
|
385
|
+
}, [query]);
|
|
386
|
+
|
|
387
|
+
// ------------------------------------------------------------------
|
|
388
|
+
// Filtering + highlight
|
|
389
|
+
// ------------------------------------------------------------------
|
|
390
|
+
const filtered = useMemo(
|
|
391
|
+
() => (open && query !== null ? options.filter((option) => filter(option, query)) : []),
|
|
392
|
+
[open, query, options, filter],
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const [activeIndexState, setActiveIndexState] = useState(0);
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* The highlight is RESOLVED during render, never repaired in an effect.
|
|
399
|
+
*
|
|
400
|
+
* `aria-activedescendant` and the option's `aria-selected` must be written in
|
|
401
|
+
* the same commit, or the attribute names an element that does not exist yet
|
|
402
|
+
* — the generalised form of cmdk's `selectedItemId` hole (see the docblock in
|
|
403
|
+
* `../command/command.tsx`). Clamping here means a stale index from before a
|
|
404
|
+
* filter keystroke can never reach the DOM, not even for one frame.
|
|
405
|
+
*/
|
|
406
|
+
const activeIndex = useMemo(() => {
|
|
407
|
+
const candidate = filtered[activeIndexState];
|
|
408
|
+
if (candidate && !candidate.disabled) return activeIndexState;
|
|
409
|
+
return firstEnabled(filtered);
|
|
410
|
+
}, [filtered, activeIndexState]);
|
|
411
|
+
|
|
412
|
+
// Reset the *stored* index when the query changes so the next arrow press
|
|
413
|
+
// starts from the top. Safe as an effect precisely because the resolved value
|
|
414
|
+
// above is already correct in the intervening commit.
|
|
415
|
+
useEffect(() => {
|
|
416
|
+
setActiveIndexState(0);
|
|
417
|
+
}, [query]);
|
|
418
|
+
|
|
419
|
+
const activeId = activeIndex >= 0 ? optionId(activeIndex) : undefined;
|
|
420
|
+
|
|
421
|
+
// ------------------------------------------------------------------
|
|
422
|
+
// Actions
|
|
423
|
+
// ------------------------------------------------------------------
|
|
424
|
+
const insert = useCallback(
|
|
425
|
+
(option: MentionOption) => {
|
|
426
|
+
if (option.disabled || !queryInfo) return;
|
|
427
|
+
const result = insertMention(value, option, trigger, queryInfo.start, caret);
|
|
428
|
+
commit(result.value);
|
|
429
|
+
applyCaret(result.caret);
|
|
430
|
+
},
|
|
431
|
+
[queryInfo, value, trigger, caret, commit, applyCaret],
|
|
432
|
+
);
|
|
433
|
+
|
|
434
|
+
const close = useCallback(() => {
|
|
435
|
+
setOpen(false);
|
|
436
|
+
}, [setOpen]);
|
|
437
|
+
|
|
438
|
+
const removeMention = useCallback(
|
|
439
|
+
(mention: Mention) => {
|
|
440
|
+
const end = mentionEnd(mention, trigger);
|
|
441
|
+
const nextText = value.text.slice(0, mention.start) + value.text.slice(end);
|
|
442
|
+
commit({ text: nextText, mentions: remapMentions(value, nextText, trigger) });
|
|
443
|
+
applyCaret(mention.start);
|
|
444
|
+
},
|
|
445
|
+
[value, trigger, commit, applyCaret],
|
|
446
|
+
);
|
|
447
|
+
|
|
448
|
+
// ------------------------------------------------------------------
|
|
449
|
+
// Textarea event handlers (owned here, handed to MentionInputTextarea)
|
|
450
|
+
// ------------------------------------------------------------------
|
|
451
|
+
const syncCaret = useCallback((element: HTMLTextAreaElement) => {
|
|
452
|
+
setCaret(element.selectionStart ?? 0);
|
|
453
|
+
}, []);
|
|
454
|
+
|
|
455
|
+
const handleChange = useCallback(
|
|
456
|
+
(element: HTMLTextAreaElement) => {
|
|
457
|
+
const nextText = element.value;
|
|
458
|
+
commit({ text: nextText, mentions: remapMentions(value, nextText, trigger) });
|
|
459
|
+
setCaret(element.selectionStart ?? nextText.length);
|
|
460
|
+
},
|
|
461
|
+
[commit, value, trigger],
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
const handleKeyDownCapture = useCallback(
|
|
465
|
+
(event: ReactKeyboardEvent<HTMLTextAreaElement>) => {
|
|
466
|
+
const element = event.currentTarget;
|
|
467
|
+
const position = element.selectionStart ?? 0;
|
|
468
|
+
const hasRange = element.selectionStart !== element.selectionEnd;
|
|
469
|
+
|
|
470
|
+
if (open) {
|
|
471
|
+
switch (event.key) {
|
|
472
|
+
case "ArrowDown":
|
|
473
|
+
event.preventDefault();
|
|
474
|
+
setActiveIndexState(step(filtered, activeIndex, 1));
|
|
475
|
+
return;
|
|
476
|
+
case "ArrowUp":
|
|
477
|
+
event.preventDefault();
|
|
478
|
+
setActiveIndexState(step(filtered, activeIndex < 0 ? 0 : activeIndex, -1));
|
|
479
|
+
return;
|
|
480
|
+
case "Home":
|
|
481
|
+
event.preventDefault();
|
|
482
|
+
setActiveIndexState(firstEnabled(filtered));
|
|
483
|
+
return;
|
|
484
|
+
case "End":
|
|
485
|
+
event.preventDefault();
|
|
486
|
+
setActiveIndexState(lastEnabled(filtered));
|
|
487
|
+
return;
|
|
488
|
+
case "Enter":
|
|
489
|
+
case "Tab": {
|
|
490
|
+
const option = activeIndex >= 0 ? filtered[activeIndex] : undefined;
|
|
491
|
+
if (option && !option.disabled) {
|
|
492
|
+
// Capture phase, so `defaultPrevented` is already true when a
|
|
493
|
+
// host handler (PromptInput's Enter→submit) runs in the bubble
|
|
494
|
+
// phase on this same element and this same SyntheticEvent.
|
|
495
|
+
event.preventDefault();
|
|
496
|
+
insert(option);
|
|
497
|
+
}
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
case "Escape":
|
|
501
|
+
event.preventDefault();
|
|
502
|
+
close();
|
|
503
|
+
return;
|
|
504
|
+
default:
|
|
505
|
+
break;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Chip atomicity. A range selection is ordinary text editing — the user
|
|
510
|
+
// asked for exactly those characters, mentions included.
|
|
511
|
+
if (hasRange || event.metaKey || event.ctrlKey || event.altKey) return;
|
|
512
|
+
|
|
513
|
+
if (event.key === "Backspace") {
|
|
514
|
+
const mention = mentionAt(value, position - 1, trigger);
|
|
515
|
+
if (mention) {
|
|
516
|
+
event.preventDefault();
|
|
517
|
+
removeMention(mention);
|
|
518
|
+
}
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
if (event.key === "Delete") {
|
|
522
|
+
const mention = mentionAt(value, position, trigger);
|
|
523
|
+
if (mention) {
|
|
524
|
+
event.preventDefault();
|
|
525
|
+
removeMention(mention);
|
|
526
|
+
}
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
if (event.key === "ArrowLeft" && !event.shiftKey) {
|
|
530
|
+
const mention = mentionAt(value, position - 1, trigger);
|
|
531
|
+
if (mention && position - 1 > mention.start) {
|
|
532
|
+
event.preventDefault();
|
|
533
|
+
applyCaret(mention.start);
|
|
534
|
+
}
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
if (event.key === "ArrowRight" && !event.shiftKey) {
|
|
538
|
+
const mention = mentionAt(value, position, trigger);
|
|
539
|
+
if (mention) {
|
|
540
|
+
event.preventDefault();
|
|
541
|
+
applyCaret(mentionEnd(mention, trigger));
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
},
|
|
545
|
+
[open, filtered, activeIndex, insert, close, value, trigger, removeMention, applyCaret],
|
|
546
|
+
);
|
|
547
|
+
|
|
548
|
+
// ------------------------------------------------------------------
|
|
549
|
+
// Mirror geometry (caret anchor + chip overlay)
|
|
550
|
+
// ------------------------------------------------------------------
|
|
551
|
+
const [mirrorStyle, setMirrorStyle] = useState<CSSProperties | null>(null);
|
|
552
|
+
const [scrollTop, setScrollTop] = useState(0);
|
|
553
|
+
const caretMarkerRef = useRef<HTMLSpanElement>(null);
|
|
554
|
+
const [caretRect, setCaretRect] = useState({ left: 0, top: 0, height: 0 });
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Re-snapshot the field's metrics whenever anything that MOVES A GLYPH can
|
|
558
|
+
* have changed.
|
|
559
|
+
*
|
|
560
|
+
* A one-shot snapshot is not enough and the failure is visible, not
|
|
561
|
+
* theoretical: the very first layout effect runs before the theme attribute
|
|
562
|
+
* has been applied, so it captures the `:root` fallback font — the chip wash
|
|
563
|
+
* then renders in a different typeface from the text it is supposed to sit
|
|
564
|
+
* behind and runs ~14% wide (measured: 143.3px vs 125.5px for the same
|
|
565
|
+
* string). Exactly the same drift happens at runtime whenever a `ThemeSwitcher`
|
|
566
|
+
* changes `data-theme`, a density dial changes the type scale, or a web font
|
|
567
|
+
* finishes loading. So the snapshot is re-taken on all of them.
|
|
568
|
+
*/
|
|
569
|
+
const remeasureMirror = useCallback(() => {
|
|
570
|
+
const element = textareaRef.current;
|
|
571
|
+
if (!element) return;
|
|
572
|
+
const next = readMirrorStyle(element);
|
|
573
|
+
setMirrorStyle((previous) =>
|
|
574
|
+
previous && JSON.stringify(previous) === JSON.stringify(next) ? previous : next,
|
|
575
|
+
);
|
|
576
|
+
}, []);
|
|
577
|
+
|
|
578
|
+
useLayoutEffect(() => {
|
|
579
|
+
remeasureMirror();
|
|
580
|
+
}, [remeasureMirror, value.text]);
|
|
581
|
+
|
|
582
|
+
useLayoutEffect(() => {
|
|
583
|
+
const element = textareaRef.current;
|
|
584
|
+
if (!element) return;
|
|
585
|
+
const teardown: Array<() => void> = [];
|
|
586
|
+
|
|
587
|
+
if (typeof ResizeObserver !== "undefined") {
|
|
588
|
+
const observer = new ResizeObserver(remeasureMirror);
|
|
589
|
+
observer.observe(element);
|
|
590
|
+
teardown.push(() => observer.disconnect());
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (typeof MutationObserver !== "undefined") {
|
|
594
|
+
// The field's own classes/inline styles.
|
|
595
|
+
const fieldObserver = new MutationObserver(remeasureMirror);
|
|
596
|
+
fieldObserver.observe(element, {
|
|
597
|
+
attributes: true,
|
|
598
|
+
attributeFilter: ["class", "style"],
|
|
599
|
+
});
|
|
600
|
+
teardown.push(() => fieldObserver.disconnect());
|
|
601
|
+
|
|
602
|
+
// Theme / density / decoration switches re-resolve the INHERITED font and
|
|
603
|
+
// type scale, and a stale snapshot misaligns every chip on the surface.
|
|
604
|
+
const rootObserver = new MutationObserver(remeasureMirror);
|
|
605
|
+
for (const node of [document.documentElement, document.body]) {
|
|
606
|
+
rootObserver.observe(node, {
|
|
607
|
+
attributes: true,
|
|
608
|
+
attributeFilter: ["data-theme", "data-density", "data-decoration", "class", "style"],
|
|
609
|
+
});
|
|
610
|
+
}
|
|
611
|
+
teardown.push(() => rootObserver.disconnect());
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// A web font landing after first paint changes every advance width. This
|
|
615
|
+
// arm is intentionally kept as unpinned defence-in-depth: five isolation
|
|
616
|
+
// attempts (issue #405) could not construct a scenario where removing it
|
|
617
|
+
// alone turns any test red — it is covered only compositely by the
|
|
618
|
+
// ResizeObserver/MutationObserver arms above. Do not delete it on the
|
|
619
|
+
// strength of "no test notices"; see #405 for the full mutation audit and
|
|
620
|
+
// the maintainer's keep decision (ADR 0023 §6).
|
|
621
|
+
let cancelled = false;
|
|
622
|
+
void document.fonts?.ready
|
|
623
|
+
.then(() => {
|
|
624
|
+
if (!cancelled) remeasureMirror();
|
|
625
|
+
})
|
|
626
|
+
.catch(() => undefined);
|
|
627
|
+
teardown.push(() => {
|
|
628
|
+
cancelled = true;
|
|
629
|
+
});
|
|
630
|
+
|
|
631
|
+
return () => {
|
|
632
|
+
for (const fn of teardown) fn();
|
|
633
|
+
};
|
|
634
|
+
}, [remeasureMirror]);
|
|
635
|
+
|
|
636
|
+
useLayoutEffect(() => {
|
|
637
|
+
if (!open) return;
|
|
638
|
+
const marker = caretMarkerRef.current;
|
|
639
|
+
const root = rootRef.current;
|
|
640
|
+
if (!marker || !root) return;
|
|
641
|
+
const rect = marker.getClientRects()[0] ?? marker.getBoundingClientRect();
|
|
642
|
+
const rootRect = root.getBoundingClientRect();
|
|
643
|
+
const next = {
|
|
644
|
+
left: rect.left - rootRect.left,
|
|
645
|
+
top: rect.top - rootRect.top,
|
|
646
|
+
height: rect.height,
|
|
647
|
+
};
|
|
648
|
+
setCaretRect((previous) =>
|
|
649
|
+
previous.left === next.left && previous.top === next.top && previous.height === next.height
|
|
650
|
+
? previous
|
|
651
|
+
: next,
|
|
652
|
+
);
|
|
653
|
+
}, [open, caret, value.text, mirrorStyle, scrollTop]);
|
|
654
|
+
|
|
655
|
+
const runs = useMemo(() => buildRuns(value, trigger), [value, trigger]);
|
|
656
|
+
|
|
657
|
+
// ------------------------------------------------------------------
|
|
658
|
+
// Context
|
|
659
|
+
// ------------------------------------------------------------------
|
|
660
|
+
const contextValue = useMemo<MentionInputContextValue>(
|
|
661
|
+
() => ({
|
|
662
|
+
state: { value, query, open, activeIndex, activeId, filtered },
|
|
663
|
+
actions: { insert, close, setActiveIndex: setActiveIndexState },
|
|
664
|
+
meta: { trigger, listId, textareaId, optionId },
|
|
665
|
+
}),
|
|
666
|
+
[
|
|
667
|
+
value,
|
|
668
|
+
query,
|
|
669
|
+
open,
|
|
670
|
+
activeIndex,
|
|
671
|
+
activeId,
|
|
672
|
+
filtered,
|
|
673
|
+
insert,
|
|
674
|
+
close,
|
|
675
|
+
trigger,
|
|
676
|
+
listId,
|
|
677
|
+
textareaId,
|
|
678
|
+
optionId,
|
|
679
|
+
],
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
const textareaContextValue = useMemo<TextareaWiring>(
|
|
683
|
+
() => ({
|
|
684
|
+
textareaRef,
|
|
685
|
+
textareaId,
|
|
686
|
+
listId,
|
|
687
|
+
open,
|
|
688
|
+
activeId,
|
|
689
|
+
value: value.text,
|
|
690
|
+
onValueInput: handleChange,
|
|
691
|
+
onCaretSync: syncCaret,
|
|
692
|
+
onKeyDownCapture: handleKeyDownCapture,
|
|
693
|
+
onDismiss: close,
|
|
694
|
+
onScrollSync: setScrollTop,
|
|
695
|
+
}),
|
|
696
|
+
[
|
|
697
|
+
textareaId,
|
|
698
|
+
listId,
|
|
699
|
+
open,
|
|
700
|
+
activeId,
|
|
701
|
+
value.text,
|
|
702
|
+
handleChange,
|
|
703
|
+
syncCaret,
|
|
704
|
+
handleKeyDownCapture,
|
|
705
|
+
close,
|
|
706
|
+
],
|
|
707
|
+
);
|
|
708
|
+
|
|
709
|
+
return (
|
|
710
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
711
|
+
<MentionInputContext value={contextValue}>
|
|
712
|
+
<TextareaWiringContext value={textareaContextValue}>
|
|
713
|
+
<div
|
|
714
|
+
ref={mergedRootRef}
|
|
715
|
+
data-slot="mention-input"
|
|
716
|
+
className={cn("relative", className)}
|
|
717
|
+
{...props}
|
|
718
|
+
>
|
|
719
|
+
{children}
|
|
720
|
+
|
|
721
|
+
{/*
|
|
722
|
+
Chip overlay — decorative, painted ON TOP of the field.
|
|
723
|
+
Every run is `text-transparent`; only a mention run paints a
|
|
724
|
+
background, and it is a 10% wash, so the real glyphs in the
|
|
725
|
+
textarea below stay legible and stay the ONLY copy of the text
|
|
726
|
+
(native selection, IME and spellcheck are untouched). It sits on
|
|
727
|
+
top rather than behind because a textarea's own background is
|
|
728
|
+
opaque (`bg-background`), which would hide anything underneath.
|
|
729
|
+
*/}
|
|
730
|
+
{mirrorStyle ? (
|
|
731
|
+
<div
|
|
732
|
+
data-slot="mention-input-overlay"
|
|
733
|
+
aria-hidden="true"
|
|
734
|
+
className="pointer-events-none select-none text-transparent"
|
|
735
|
+
style={{ ...mirrorStyle, zIndex: 1 }}
|
|
736
|
+
>
|
|
737
|
+
<div style={{ transform: `translateY(${-scrollTop}px)` }}>
|
|
738
|
+
{runs.map((run) =>
|
|
739
|
+
run.mention ? (
|
|
740
|
+
<span
|
|
741
|
+
key={run.key}
|
|
742
|
+
data-slot="mention-input-chip"
|
|
743
|
+
className="box-decoration-clone rounded-sm bg-primary/10"
|
|
744
|
+
>
|
|
745
|
+
{run.text}
|
|
746
|
+
</span>
|
|
747
|
+
) : (
|
|
748
|
+
<span key={run.key}>{run.text}</span>
|
|
749
|
+
),
|
|
750
|
+
)}
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
) : null}
|
|
754
|
+
|
|
755
|
+
{/* Caret measurement mirror — laid out, never painted. */}
|
|
756
|
+
{open && mirrorStyle ? (
|
|
757
|
+
<div
|
|
758
|
+
data-slot="mention-input-caret-mirror"
|
|
759
|
+
aria-hidden="true"
|
|
760
|
+
className="pointer-events-none select-none"
|
|
761
|
+
style={{ ...mirrorStyle, visibility: "hidden", zIndex: -1 }}
|
|
762
|
+
>
|
|
763
|
+
<div style={{ transform: `translateY(${-scrollTop}px)` }}>
|
|
764
|
+
{value.text.slice(0, caret)}
|
|
765
|
+
<span ref={caretMarkerRef}>{value.text.slice(caret) || "."}</span>
|
|
766
|
+
</div>
|
|
767
|
+
</div>
|
|
768
|
+
) : null}
|
|
769
|
+
|
|
770
|
+
<PopoverAnchor asChild>
|
|
771
|
+
<span
|
|
772
|
+
data-slot="mention-input-anchor"
|
|
773
|
+
aria-hidden="true"
|
|
774
|
+
className="pointer-events-none absolute"
|
|
775
|
+
style={{
|
|
776
|
+
left: caretRect.left,
|
|
777
|
+
top: caretRect.top,
|
|
778
|
+
width: 0,
|
|
779
|
+
height: caretRect.height,
|
|
780
|
+
}}
|
|
781
|
+
/>
|
|
782
|
+
</PopoverAnchor>
|
|
783
|
+
</div>
|
|
784
|
+
</TextareaWiringContext>
|
|
785
|
+
</MentionInputContext>
|
|
786
|
+
</Popover>
|
|
787
|
+
);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
// ---------------------------------------------------------------------------
|
|
791
|
+
// MentionInputTextarea
|
|
792
|
+
// ---------------------------------------------------------------------------
|
|
793
|
+
|
|
794
|
+
interface TextareaWiring {
|
|
795
|
+
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
|
|
796
|
+
textareaId: string;
|
|
797
|
+
listId: string;
|
|
798
|
+
open: boolean;
|
|
799
|
+
activeId?: string;
|
|
800
|
+
value: string;
|
|
801
|
+
onValueInput: (element: HTMLTextAreaElement) => void;
|
|
802
|
+
onCaretSync: (element: HTMLTextAreaElement) => void;
|
|
803
|
+
onKeyDownCapture: (event: ReactKeyboardEvent<HTMLTextAreaElement>) => void;
|
|
804
|
+
onDismiss: () => void;
|
|
805
|
+
onScrollSync: (scrollTop: number) => void;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
const TextareaWiringContext = createContext<TextareaWiring | null>(null);
|
|
809
|
+
|
|
810
|
+
export interface MentionInputTextareaProps extends Omit<
|
|
811
|
+
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
|
812
|
+
"value" | "defaultValue"
|
|
813
|
+
> {
|
|
814
|
+
/**
|
|
815
|
+
* Render the consumer's own textarea-rendering component instead of a
|
|
816
|
+
* `Textarea` — e.g. `PromptInputTextarea`. The child must ultimately render a
|
|
817
|
+
* real `<textarea>`; everything this component needs is spread onto it.
|
|
818
|
+
*/
|
|
819
|
+
asChild?: boolean;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
/**
|
|
823
|
+
* The field itself — a real `<textarea>` carrying the combobox semantics.
|
|
824
|
+
*
|
|
825
|
+
* The mention interception is bound as **`onKeyDownCapture`**, not `onKeyDown`.
|
|
826
|
+
* Under `asChild`, Radix `Slot` merges CHILD handlers over slot handlers and
|
|
827
|
+
* runs the child's **first**. So when the child binds `onKeyDown` directly on
|
|
828
|
+
* the host element, a bubble-phase handler here would fire *after* it — the
|
|
829
|
+
* child's Enter→submit would win and the popup would never see the key. React
|
|
830
|
+
* dispatches an element's capture pass before its bubble pass and shares one
|
|
831
|
+
* `SyntheticEvent` between them, so `preventDefault()` here is already visible
|
|
832
|
+
* as `event.defaultPrevented` when the child's handler runs.
|
|
833
|
+
*
|
|
834
|
+
* **Scope of the hazard, stated precisely** (an earlier version of this comment
|
|
835
|
+
* overclaimed): it applies to a child that binds `onKeyDown` on a *host
|
|
836
|
+
* element*. It does NOT apply to `PromptInputTextarea`, which is a *component*
|
|
837
|
+
* that destructures `onKeyDown` out of its own props, calls it first and bails
|
|
838
|
+
* on `defaultPrevented` (`packages/ai/src/prompt-input.tsx`) — that composition
|
|
839
|
+
* works with either binding. The capture binding is what makes the raw-host
|
|
840
|
+
* case correct too, and that is the case the locking test exercises: see
|
|
841
|
+
* "T8 Slot handler-order contract" in `mention-input.test.tsx`, which fails if
|
|
842
|
+
* this is ever moved to `onKeyDown`.
|
|
843
|
+
*/
|
|
844
|
+
export const MentionInputTextarea = forwardRef<HTMLTextAreaElement, MentionInputTextareaProps>(
|
|
845
|
+
function MentionInputTextarea(
|
|
846
|
+
{
|
|
847
|
+
asChild,
|
|
848
|
+
className,
|
|
849
|
+
// The seven handlers this component owns are destructured OUT of `props`
|
|
850
|
+
// so `{...props}` can be spread BEFORE them without clobbering them.
|
|
851
|
+
// Spreading props last (the obvious reading of "spread ...props onto the
|
|
852
|
+
// root") silently replaced the component's own wiring: a consumer passing
|
|
853
|
+
// `onChange` used to knock out `wiring.onValueInput`, and since the value
|
|
854
|
+
// is provider-controlled that left the field completely inert. Consumer
|
|
855
|
+
// handlers still run — each one is invoked from inside its counterpart —
|
|
856
|
+
// and every NON-handler prop (`id`, `name`, `aria-*`, `placeholder`, …)
|
|
857
|
+
// still wins, because the spread sits after those defaults.
|
|
858
|
+
onChange,
|
|
859
|
+
onKeyDownCapture,
|
|
860
|
+
onSelect,
|
|
861
|
+
onKeyUp,
|
|
862
|
+
onClick,
|
|
863
|
+
onScroll,
|
|
864
|
+
onBlur,
|
|
865
|
+
...props
|
|
866
|
+
},
|
|
867
|
+
ref,
|
|
868
|
+
) {
|
|
869
|
+
const wiring = use(TextareaWiringContext);
|
|
870
|
+
if (!wiring) {
|
|
871
|
+
throw new Error("MentionInputTextarea must be used inside a <MentionInput>.");
|
|
872
|
+
}
|
|
873
|
+
const mergedRef = useMemo(
|
|
874
|
+
() => mergeRefs<HTMLTextAreaElement>(ref, wiring.textareaRef),
|
|
875
|
+
[ref, wiring.textareaRef],
|
|
876
|
+
);
|
|
877
|
+
|
|
878
|
+
const Comp = asChild ? Slot : Textarea;
|
|
879
|
+
|
|
880
|
+
return (
|
|
881
|
+
<Comp
|
|
882
|
+
ref={mergedRef}
|
|
883
|
+
id={wiring.textareaId}
|
|
884
|
+
data-slot="mention-input-textarea"
|
|
885
|
+
// Machine-readable open state. It is a `data-` attribute rather than
|
|
886
|
+
// `aria-expanded` because `aria-expanded` is NOT in `textbox`'s
|
|
887
|
+
// supported-property set — see the ARIA note below.
|
|
888
|
+
data-state={wiring.open ? "open" : "closed"}
|
|
889
|
+
// The field stays an implicit `textbox`. `role="combobox"` on a
|
|
890
|
+
// `<textarea>` is invalid per ARIA in HTML (`<textarea>` allows no role
|
|
891
|
+
// override) and axe's `aria-allowed-role` — enabled by default — flags
|
|
892
|
+
// it on every story; `aria-expanded` on a textbox is worse still
|
|
893
|
+
// (`aria-allowed-attr`, CRITICAL). All four attributes below ARE in
|
|
894
|
+
// `textbox`'s supported set, so this arrangement is both spec-valid and
|
|
895
|
+
// axe-clean while keeping the announcement that matters: with
|
|
896
|
+
// `aria-activedescendant` on a textbox, AT reads the highlighted option
|
|
897
|
+
// as the user arrows through the roster.
|
|
898
|
+
aria-autocomplete="list"
|
|
899
|
+
aria-haspopup="listbox"
|
|
900
|
+
// Only while the popup is mounted: the listbox lives in a Radix portal
|
|
901
|
+
// that unmounts on close, and an `aria-controls` pointing at a removed
|
|
902
|
+
// node is a dangling reference.
|
|
903
|
+
aria-controls={wiring.open ? wiring.listId : undefined}
|
|
904
|
+
aria-activedescendant={wiring.activeId}
|
|
905
|
+
autoComplete="off"
|
|
906
|
+
value={wiring.value}
|
|
907
|
+
className={cn("relative z-0", className)}
|
|
908
|
+
{...props}
|
|
909
|
+
onChange={(event) => {
|
|
910
|
+
wiring.onValueInput(event.currentTarget);
|
|
911
|
+
onChange?.(event);
|
|
912
|
+
}}
|
|
913
|
+
onKeyDownCapture={(event) => {
|
|
914
|
+
wiring.onKeyDownCapture(event);
|
|
915
|
+
onKeyDownCapture?.(event);
|
|
916
|
+
}}
|
|
917
|
+
onSelect={(event) => {
|
|
918
|
+
wiring.onCaretSync(event.currentTarget);
|
|
919
|
+
onSelect?.(event);
|
|
920
|
+
}}
|
|
921
|
+
onKeyUp={(event) => {
|
|
922
|
+
wiring.onCaretSync(event.currentTarget);
|
|
923
|
+
onKeyUp?.(event);
|
|
924
|
+
}}
|
|
925
|
+
onClick={(event) => {
|
|
926
|
+
wiring.onCaretSync(event.currentTarget);
|
|
927
|
+
onClick?.(event);
|
|
928
|
+
}}
|
|
929
|
+
onScroll={(event) => {
|
|
930
|
+
wiring.onScrollSync(event.currentTarget.scrollTop);
|
|
931
|
+
onScroll?.(event);
|
|
932
|
+
}}
|
|
933
|
+
onBlur={(event) => {
|
|
934
|
+
wiring.onDismiss();
|
|
935
|
+
onBlur?.(event);
|
|
936
|
+
}}
|
|
937
|
+
/>
|
|
938
|
+
);
|
|
939
|
+
},
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
// ---------------------------------------------------------------------------
|
|
943
|
+
// MentionInputContent / List / Item / Empty
|
|
944
|
+
// ---------------------------------------------------------------------------
|
|
945
|
+
|
|
946
|
+
export type MentionInputContentProps = React.ComponentPropsWithoutRef<typeof PopoverContent>;
|
|
947
|
+
|
|
948
|
+
/** The floating panel. Never takes focus — the caret has to stay in the field. */
|
|
949
|
+
export const MentionInputContent = forwardRef<
|
|
950
|
+
React.ElementRef<typeof PopoverContent>,
|
|
951
|
+
MentionInputContentProps
|
|
952
|
+
>(function MentionInputContent({ className, align = "start", ...props }, ref) {
|
|
953
|
+
const { meta } = useMentionInput();
|
|
954
|
+
const wiring = use(TextareaWiringContext);
|
|
955
|
+
|
|
956
|
+
return (
|
|
957
|
+
<PopoverContent
|
|
958
|
+
ref={ref}
|
|
959
|
+
data-slot="mention-input-content"
|
|
960
|
+
align={align}
|
|
961
|
+
// Radix `PopoverContent` defaults to `role="dialog"`, which is wrong here
|
|
962
|
+
// twice over: a combobox popup is not a dialog, and an unnamed dialog is
|
|
963
|
+
// an axe `aria-dialog-name` violation (serious). The panel is a plain
|
|
964
|
+
// container — the `role="listbox"` inside carries all the semantics.
|
|
965
|
+
role="presentation"
|
|
966
|
+
// No border, no ring: PopoverContent already carries `shadow-ring-md`.
|
|
967
|
+
className={cn("w-72 p-0", className)}
|
|
968
|
+
onOpenAutoFocus={(event) => event.preventDefault()}
|
|
969
|
+
onCloseAutoFocus={(event) => event.preventDefault()}
|
|
970
|
+
onPointerDownOutside={(event) => {
|
|
971
|
+
// Clicking the field itself only moves the caret; it must not count as
|
|
972
|
+
// a dismissal, or the popup would fight the caret it depends on.
|
|
973
|
+
const target = event.detail.originalEvent.target as Node | null;
|
|
974
|
+
if (target && wiring?.textareaRef.current?.contains(target)) event.preventDefault();
|
|
975
|
+
props.onPointerDownOutside?.(event);
|
|
976
|
+
}}
|
|
977
|
+
id={`${meta.listId}-content`}
|
|
978
|
+
{...props}
|
|
979
|
+
/>
|
|
980
|
+
);
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
export interface MentionInputListProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
984
|
+
/**
|
|
985
|
+
* Either static nodes, or a render function called once per filtered option.
|
|
986
|
+
* The function form is the one to reach for — the parent has to hand each
|
|
987
|
+
* option back, which is exactly what `children`-as-a-function is for.
|
|
988
|
+
*/
|
|
989
|
+
children?: ReactNode | ((option: MentionOption, index: number) => ReactNode);
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/** The listbox. Owns the accessible name and the option mapping. */
|
|
993
|
+
export const MentionInputList = forwardRef<HTMLDivElement, MentionInputListProps>(
|
|
994
|
+
function MentionInputList({ className, children, ...props }, ref) {
|
|
995
|
+
const { t } = useLocale();
|
|
996
|
+
const { state, meta } = useMentionInput();
|
|
997
|
+
|
|
998
|
+
return (
|
|
999
|
+
<div
|
|
1000
|
+
ref={ref}
|
|
1001
|
+
id={meta.listId}
|
|
1002
|
+
data-slot="mention-input-list"
|
|
1003
|
+
role="listbox"
|
|
1004
|
+
aria-label={t("ui.mentionInput.listLabel")}
|
|
1005
|
+
className={cn("max-h-60 overflow-y-auto p-1", className)}
|
|
1006
|
+
{...props}
|
|
1007
|
+
>
|
|
1008
|
+
{typeof children === "function"
|
|
1009
|
+
? state.filtered.map((option, index) => children(option, index))
|
|
1010
|
+
: children}
|
|
1011
|
+
</div>
|
|
1012
|
+
);
|
|
1013
|
+
},
|
|
1014
|
+
);
|
|
1015
|
+
|
|
1016
|
+
export interface MentionInputItemProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
|
|
1017
|
+
option: MentionOption;
|
|
1018
|
+
children?: ReactNode;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* One option row.
|
|
1023
|
+
*
|
|
1024
|
+
* The row derives its own index from the provider's `filtered` list rather than
|
|
1025
|
+
* accepting one, so its `id` and `aria-selected` can never disagree with the
|
|
1026
|
+
* `aria-activedescendant` the field is publishing.
|
|
1027
|
+
*/
|
|
1028
|
+
export const MentionInputItem = forwardRef<HTMLDivElement, MentionInputItemProps>(
|
|
1029
|
+
function MentionInputItem({ option, className, children, ...props }, ref) {
|
|
1030
|
+
const { state, actions, meta } = useMentionInput();
|
|
1031
|
+
const index = state.filtered.findIndex((candidate) => candidate.id === option.id);
|
|
1032
|
+
const isActive = index >= 0 && index === state.activeIndex;
|
|
1033
|
+
|
|
1034
|
+
return (
|
|
1035
|
+
<div
|
|
1036
|
+
ref={ref}
|
|
1037
|
+
id={index >= 0 ? meta.optionId(index) : undefined}
|
|
1038
|
+
data-slot="mention-input-item"
|
|
1039
|
+
role="option"
|
|
1040
|
+
aria-selected={isActive}
|
|
1041
|
+
aria-disabled={option.disabled || undefined}
|
|
1042
|
+
data-active={isActive || undefined}
|
|
1043
|
+
className={cn(
|
|
1044
|
+
"relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5",
|
|
1045
|
+
"text-body outline-none transition-colors duration-fast motion-reduce:transition-none",
|
|
1046
|
+
"data-[active=true]:bg-accent data-[active=true]:text-accent-foreground",
|
|
1047
|
+
"aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:size-4",
|
|
1048
|
+
className,
|
|
1049
|
+
)}
|
|
1050
|
+
// Keep the caret where it is: a mousedown inside the portal would
|
|
1051
|
+
// otherwise blur the textarea before the click ever lands.
|
|
1052
|
+
onMouseDown={(event) => {
|
|
1053
|
+
event.preventDefault();
|
|
1054
|
+
props.onMouseDown?.(event);
|
|
1055
|
+
}}
|
|
1056
|
+
onClick={(event) => {
|
|
1057
|
+
actions.insert(option);
|
|
1058
|
+
props.onClick?.(event);
|
|
1059
|
+
}}
|
|
1060
|
+
onMouseEnter={(event) => {
|
|
1061
|
+
if (!option.disabled && index >= 0) actions.setActiveIndex(index);
|
|
1062
|
+
props.onMouseEnter?.(event);
|
|
1063
|
+
}}
|
|
1064
|
+
{...props}
|
|
1065
|
+
>
|
|
1066
|
+
{children ?? (
|
|
1067
|
+
<span className="flex min-w-0 flex-col">
|
|
1068
|
+
<span className="truncate">{option.label}</span>
|
|
1069
|
+
{option.description ? (
|
|
1070
|
+
<span className="truncate text-meta text-muted-foreground">{option.description}</span>
|
|
1071
|
+
) : null}
|
|
1072
|
+
</span>
|
|
1073
|
+
)}
|
|
1074
|
+
</div>
|
|
1075
|
+
);
|
|
1076
|
+
},
|
|
1077
|
+
);
|
|
1078
|
+
|
|
1079
|
+
export type MentionInputEmptyProps = HTMLAttributes<HTMLDivElement>;
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* The "nothing matches" line. A **sibling** of the listbox, never a child —
|
|
1083
|
+
* a `role="listbox"` may only own `option` and `group` children.
|
|
1084
|
+
*/
|
|
1085
|
+
export const MentionInputEmpty = forwardRef<HTMLDivElement, MentionInputEmptyProps>(
|
|
1086
|
+
function MentionInputEmpty({ className, children, ...props }, ref) {
|
|
1087
|
+
const { t } = useLocale();
|
|
1088
|
+
const { state } = useMentionInput();
|
|
1089
|
+
if (state.filtered.length > 0) return null;
|
|
1090
|
+
|
|
1091
|
+
return (
|
|
1092
|
+
<div
|
|
1093
|
+
ref={ref}
|
|
1094
|
+
data-slot="mention-input-empty"
|
|
1095
|
+
className={cn("py-6 text-center text-body text-muted-foreground", className)}
|
|
1096
|
+
{...props}
|
|
1097
|
+
>
|
|
1098
|
+
{children ?? t("noResults")}
|
|
1099
|
+
</div>
|
|
1100
|
+
);
|
|
1101
|
+
},
|
|
1102
|
+
);
|