@elabs-ai/components-ui 4.0.0 → 4.2.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/README.md +10 -8
- package/dist/chunk-FCH4ZN6G.js +57 -0
- package/dist/chunk-FCH4ZN6G.js.map +1 -0
- package/dist/form.d.ts +28 -0
- package/dist/form.js +123 -0
- package/dist/form.js.map +1 -0
- package/dist/index.d.ts +2597 -188
- package/dist/index.js +12855 -3578
- package/dist/index.js.map +1 -1
- package/dist/lib/cn.js +10 -1
- package/dist/lib/cn.js.map +1 -1
- package/package.json +21 -7
- package/src/__contract__/attribution-panel.contract.test.tsx +49 -0
- package/src/__contract__/badge.contract.test.tsx +49 -0
- package/src/__contract__/bounded-number.contract.test.tsx +49 -0
- package/src/__contract__/button.contract.test.tsx +49 -0
- package/src/__contract__/color-picker.contract.test.tsx +49 -0
- package/src/__contract__/combobox.contract.test.tsx +49 -0
- package/src/__contract__/command-trigger.contract.test.tsx +49 -0
- package/src/__contract__/copyable-value.contract.test.tsx +49 -0
- package/src/__contract__/date-picker.contract.test.tsx +49 -0
- package/src/__contract__/date-range-picker.contract.test.tsx +49 -0
- package/src/__contract__/empty-state.contract.test.tsx +49 -0
- package/src/__contract__/error-state.contract.test.tsx +49 -0
- package/src/__contract__/icon-button.contract.test.tsx +49 -0
- package/src/__contract__/input.contract.test.tsx +49 -0
- package/src/__contract__/key-value-editor.contract.test.tsx +49 -0
- package/src/__contract__/list-editor.contract.test.tsx +49 -0
- package/src/__contract__/loading-state.contract.test.tsx +49 -0
- package/src/__contract__/metric-card.contract.test.tsx +49 -0
- package/src/__contract__/model-picker.contract.test.tsx +49 -0
- package/src/__contract__/nav-notifications.contract.test.tsx +49 -0
- package/src/__contract__/number-input.contract.test.tsx +49 -0
- package/src/__contract__/rating.contract.test.tsx +49 -0
- package/src/__contract__/schema-form.contract.test.tsx +49 -0
- package/src/__contract__/section-header.contract.test.tsx +49 -0
- package/src/__contract__/segmented-field.contract.test.tsx +49 -0
- package/src/__contract__/slider-number.contract.test.tsx +49 -0
- package/src/__contract__/status-badge.contract.test.tsx +49 -0
- package/src/__contract__/tag-input.contract.test.tsx +49 -0
- package/src/__contract__/text.contract.test.tsx +49 -0
- package/src/__contract__/textarea.contract.test.tsx +49 -0
- package/src/__contract__/timeline.contract.test.tsx +49 -0
- package/src/__contract__/toggle.contract.test.tsx +49 -0
- package/src/__contract__/transfer.contract.test.tsx +49 -0
- package/src/__contract__/tree-select.contract.test.tsx +49 -0
- package/src/__contract__/tree.contract.test.tsx +49 -0
- package/src/__contract__/virtual-select.contract.test.tsx +49 -0
- package/src/__contract__/workspace-picker.contract.test.tsx +49 -0
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +13 -4
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +15 -4
- package/src/components/alert-dialog/alert-dialog.test.tsx +32 -0
- package/src/components/alert-dialog/alert-dialog.tsx +28 -6
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.test.tsx +99 -0
- package/src/components/app-shell/app-shell.tsx +56 -4
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +3 -1
- package/src/components/attribution-panel/attributions.generated.ts +83 -79
- package/src/components/avatar/avatar.tsx +4 -1
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.test.tsx +8 -0
- package/src/components/badge/badge.tsx +15 -5
- package/src/components/bento-grid/bento-grid.test.tsx +24 -3
- package/src/components/bento-grid/bento-grid.tsx +11 -1
- package/src/components/bounded-number/bounded-number.test.tsx +1 -1
- package/src/components/bounded-number/bounded-number.tsx +78 -75
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +10 -4
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +18 -12
- package/src/components/button-group/button-group.tsx +1 -1
- package/src/components/calendar/calendar.tsx +3 -2
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.test.tsx +24 -2
- package/src/components/card/card.tsx +48 -7
- package/src/components/carousel/carousel.test.tsx +169 -0
- package/src/components/carousel/carousel.tsx +93 -32
- package/src/components/change-review/change-review.stories.tsx +145 -1
- package/src/components/change-review/change-review.test.tsx +143 -0
- package/src/components/change-review/change-review.tsx +214 -24
- package/src/components/checkbox/checkbox.tsx +2 -1
- package/src/components/collapsible-panel/use-collapsible-panel.ts +0 -3
- package/src/components/color-picker/color-picker.tsx +22 -15
- package/src/components/combobox/combobox.stories.tsx +10 -0
- package/src/components/combobox/combobox.test.tsx +54 -0
- package/src/components/combobox/combobox.tsx +25 -11
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +88 -0
- package/src/components/command/command.tsx +38 -13
- package/src/components/command/index.ts +1 -0
- package/src/components/command-trigger/command-trigger.stories.tsx +39 -0
- package/src/components/command-trigger/command-trigger.test.tsx +18 -0
- package/src/components/command-trigger/command-trigger.tsx +70 -0
- package/src/components/command-trigger/index.ts +1 -0
- package/src/components/context-menu/context-menu.tsx +16 -7
- package/src/components/context-rail/context-rail.stories.tsx +285 -0
- package/src/components/context-rail/context-rail.test.tsx +263 -0
- package/src/components/context-rail/context-rail.tsx +678 -0
- package/src/components/context-rail/index.ts +1 -0
- package/src/components/copyable-value/copyable-value.stories.tsx +10 -0
- package/src/components/copyable-value/copyable-value.test.tsx +28 -1
- package/src/components/copyable-value/copyable-value.tsx +13 -2
- package/src/components/date-picker/date-picker.stories.tsx +9 -0
- package/src/components/date-picker/date-picker.test.tsx +52 -0
- package/src/components/date-picker/date-picker.tsx +13 -15
- package/src/components/date-range-picker/date-range-picker.stories.tsx +9 -0
- package/src/components/date-range-picker/date-range-picker.test.tsx +76 -1
- package/src/components/date-range-picker/date-range-picker.tsx +39 -28
- package/src/components/descriptions/descriptions.tsx +1 -1
- package/src/components/dialog/dialog.test.tsx +8 -3
- package/src/components/dialog/dialog.tsx +14 -6
- package/src/components/drawer/drawer.tsx +23 -6
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- package/src/components/dropdown-menu/dropdown-menu.tsx +4 -4
- package/src/components/empty-state/empty-state.test.tsx +26 -0
- package/src/components/empty-state/empty-state.tsx +6 -1
- package/src/components/expand-dialog/expand-dialog.tsx +1 -2
- package/src/components/field/field-context.ts +59 -0
- package/src/components/field/field.stories.tsx +142 -0
- package/src/components/field/field.test.tsx +415 -0
- package/src/components/field/field.tsx +342 -0
- package/src/components/field/index.ts +9 -0
- package/src/components/field-row/field-row.stories.tsx +85 -1
- package/src/components/field-row/field-row.test.tsx +145 -0
- package/src/components/field-row/field-row.tsx +50 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +90 -12
- package/src/components/file-upload/file-upload.tsx +164 -31
- package/src/components/form/form.tsx +8 -3
- package/src/components/hover-card/hover-card.tsx +1 -0
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +3 -3
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +8 -4
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +18 -9
- package/src/components/input-otp/input-otp.tsx +8 -2
- package/src/components/keyboard-shortcuts/index.ts +6 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.stories.tsx +177 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.test.tsx +73 -0
- package/src/components/keyboard-shortcuts/keyboard-shortcuts.tsx +203 -0
- package/src/components/label/label.tsx +1 -1
- package/src/components/link-preview/link-preview.tsx +1 -1
- package/src/components/list-editor/list-editor.test.tsx +24 -0
- package/src/components/list-editor/list-editor.tsx +126 -52
- package/src/components/locale-provider/index.ts +6 -1
- package/src/components/locale-provider/locale-keys.test.ts +134 -0
- package/src/components/locale-provider/locale-provider.stories.tsx +89 -1
- package/src/components/locale-provider/locale-provider.test.tsx +257 -1
- package/src/components/locale-provider/locale-provider.tsx +63 -8
- package/src/components/locale-provider/messages.ts +822 -2
- package/src/components/match-highlight/match-highlight.stories.tsx +1 -1
- package/src/components/mention-input/mention-input-mirror.stories.tsx +9 -1
- package/src/components/mention-input/mention-input.stories.tsx +3 -3
- package/src/components/mention-input/mention-input.test.tsx +51 -1
- package/src/components/mention-input/mention-input.tsx +66 -46
- package/src/components/mention-input/mention-value.ts +8 -2
- package/src/components/menubar/menubar.tsx +16 -6
- package/src/components/metric-card/metric-card.stories.tsx +5 -3
- package/src/components/metric-card/metric-card.tsx +21 -2
- package/src/components/model-picker/model-picker.stories.tsx +63 -3
- package/src/components/model-picker/model-picker.test.tsx +63 -0
- package/src/components/model-picker/model-picker.tsx +62 -36
- package/src/components/nav-main/nav-main.tsx +8 -4
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.test.tsx +11 -0
- package/src/components/nav-notifications/nav-notifications.tsx +67 -9
- package/src/components/nav-user/nav-user.tsx +5 -5
- package/src/components/navigation-menu/navigation-menu.stories.tsx +22 -0
- package/src/components/navigation-menu/navigation-menu.test.tsx +174 -0
- package/src/components/navigation-menu/navigation-menu.tsx +204 -15
- package/src/components/number-input/number-input.stories.tsx +7 -3
- package/src/components/number-input/number-input.test.tsx +47 -0
- package/src/components/number-input/number-input.tsx +65 -24
- package/src/components/page-shell/page-shell.stories.tsx +163 -0
- package/src/components/page-shell/page-shell.test.tsx +229 -0
- package/src/components/page-shell/page-shell.tsx +108 -25
- package/src/components/pagination/pagination.test.tsx +109 -0
- package/src/components/pagination/pagination.tsx +27 -19
- package/src/components/popover/popover.tsx +1 -0
- package/src/components/progress/progress.tsx +2 -0
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +3 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.test.tsx +76 -0
- package/src/components/resizable/resizable.tsx +32 -6
- package/src/components/revision-timeline/revision-timeline.stories.tsx +8 -1
- package/src/components/revision-timeline/revision-timeline.test.tsx +26 -0
- package/src/components/revision-timeline/revision-timeline.tsx +53 -5
- package/src/components/schema-form/from-json-schema.test.ts +390 -0
- package/src/components/schema-form/from-json-schema.ts +375 -0
- package/src/components/schema-form/index.ts +81 -0
- package/src/components/schema-form/schema-form-spec.ts +698 -0
- package/src/components/schema-form/schema-form-store.ts +506 -0
- package/src/components/schema-form/schema-form.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +1021 -0
- package/src/components/schema-form/schema-form.tsx +1381 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +14 -3
- package/src/components/select/select.tsx +13 -7
- package/src/components/separator/separator.tsx +1 -0
- package/src/components/sheet/sheet.test.tsx +39 -0
- package/src/components/sheet/sheet.tsx +40 -9
- package/src/components/side-dock/index.ts +1 -0
- package/src/components/side-dock/side-dock.stories.tsx +137 -0
- package/src/components/side-dock/side-dock.test.tsx +299 -0
- package/src/components/side-dock/side-dock.tsx +515 -0
- package/src/components/sidebar/index.ts +1 -0
- package/src/components/sidebar/sidebar-frame.test.tsx +162 -0
- package/src/components/sidebar/sidebar.stories.tsx +421 -1
- package/src/components/sidebar/sidebar.test.tsx +71 -2
- package/src/components/sidebar/sidebar.tsx +264 -38
- package/src/components/skeleton/skeleton.tsx +1 -0
- package/src/components/skip-link/index.ts +1 -0
- package/src/components/skip-link/skip-link.stories.tsx +106 -0
- package/src/components/skip-link/skip-link.test.tsx +20 -0
- package/src/components/skip-link/skip-link.tsx +34 -0
- package/src/components/slider/slider.tsx +1 -1
- package/src/components/slider-number/slider-number.stories.tsx +4 -1
- package/src/components/slider-number/slider-number.test.tsx +1 -1
- package/src/components/sonner/sonner.tsx +2 -0
- package/src/components/spinner/spinner.test.tsx +18 -0
- package/src/components/spinner/spinner.tsx +14 -4
- package/src/components/split-panel/index.ts +6 -1
- package/src/components/split-panel/split-panel.stories.tsx +18 -2
- package/src/components/split-panel/split-panel.test.tsx +20 -0
- package/src/components/split-panel/split-panel.tsx +28 -8
- package/src/components/state-panel/state-panel.stories.tsx +52 -0
- package/src/components/state-panel/state-panel.test.tsx +125 -0
- package/src/components/state-panel/state-panel.tsx +110 -24
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +3 -1
- package/src/components/table/table.stories.tsx +64 -0
- package/src/components/table/table.test.tsx +149 -1
- package/src/components/table/table.tsx +71 -2
- package/src/components/tabs/index.ts +10 -1
- package/src/components/tabs/tabs.stories.tsx +48 -0
- package/src/components/tabs/tabs.test.tsx +67 -0
- package/src/components/tabs/tabs.tsx +176 -72
- package/src/components/tag-input/tag-input.tsx +6 -3
- package/src/components/team-switcher/team-switcher.tsx +35 -6
- package/src/components/textarea/textarea.tsx +2 -2
- package/src/components/theme-switcher/theme-switcher.stories.tsx +76 -0
- package/src/components/theme-switcher/theme-switcher.test.tsx +182 -0
- package/src/components/theme-switcher/theme-switcher.tsx +140 -12
- package/src/components/theme-switcher/use-theme-transition.ts +2 -2
- package/src/components/timeline/timeline.stories.tsx +27 -0
- package/src/components/timeline/timeline.tsx +10 -3
- package/src/components/toggle/toggle.tsx +7 -2
- package/src/components/toggle-group/toggle-group.tsx +2 -0
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tooltip/tooltip.tsx +2 -1
- package/src/components/top-nav/top-nav.tsx +1 -1
- package/src/components/transfer/transfer.tsx +35 -30
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.test.tsx +41 -0
- package/src/components/tree/tree.tsx +91 -31
- package/src/components/tree/use-tree-keyboard.ts +37 -14
- package/src/components/tree-select/tree-select.stories.tsx +9 -0
- package/src/components/tree-select/tree-select.test.tsx +5 -0
- package/src/components/tree-select/tree-select.tsx +11 -14
- package/src/components/typography/prose.test.tsx +3 -2
- package/src/components/typography/prose.tsx +3 -3
- package/src/components/typography/typography.stories.tsx +228 -0
- package/src/components/view-toolbar/view-toolbar.stories.tsx +41 -1
- package/src/components/view-toolbar/view-toolbar.test.tsx +33 -0
- package/src/components/view-toolbar/view-toolbar.tsx +37 -4
- package/src/components/virtual-select/virtual-select.stories.tsx +9 -0
- package/src/components/virtual-select/virtual-select.test.tsx +29 -3
- package/src/components/virtual-select/virtual-select.tsx +56 -18
- package/src/components/wizard/wizard.test.tsx +9 -0
- package/src/components/wizard/wizard.tsx +13 -5
- package/src/components/workspace-picker/index.ts +3 -0
- package/src/components/workspace-picker/workspace-picker-state.ts +50 -0
- package/src/components/workspace-picker/workspace-picker.stories.tsx +126 -0
- package/src/components/workspace-picker/workspace-picker.test.tsx +207 -0
- package/src/components/workspace-picker/workspace-picker.tsx +172 -0
- package/src/illustrations/empty-list-illustration.tsx +26 -0
- package/src/illustrations/error-illustration.tsx +60 -0
- package/src/illustrations/first-run-illustration.tsx +27 -0
- package/src/illustrations/illustration-base.tsx +108 -0
- package/src/illustrations/illustrations.stories.tsx +94 -0
- package/src/illustrations/illustrations.test.tsx +98 -0
- package/src/illustrations/index.ts +16 -0
- package/src/illustrations/no-access-illustration.tsx +45 -0
- package/src/illustrations/no-results-illustration.tsx +46 -0
- package/src/illustrations/offline-illustration.tsx +37 -0
- package/src/illustrations/success-illustration.tsx +40 -0
- package/src/index.ts +80 -2
- package/src/lib/agent-event-model.test.ts +12 -0
- package/src/lib/agent-event-model.ts +42 -0
- package/src/lib/approval-option.test.ts +17 -0
- package/src/lib/approval-option.ts +53 -0
- package/src/lib/check-result.ts +33 -0
- package/src/lib/cn.ts +9 -0
- package/src/lib/csv.ts +48 -0
- package/src/lib/diff-line.test.ts +36 -0
- package/src/lib/diff-line.ts +63 -0
- package/src/lib/diff-rows.test.ts +76 -0
- package/src/lib/diff-rows.ts +94 -0
- package/src/lib/format-duration.test.ts +22 -0
- package/src/lib/format-duration.ts +30 -0
- package/src/lib/has-renderable-content.test.ts +49 -0
- package/src/lib/has-renderable-content.ts +32 -0
- package/src/lib/merge-refs.ts +2 -0
- package/src/lib/operating-mode.test.ts +36 -0
- package/src/lib/operating-mode.ts +81 -0
- package/src/lib/optional-peer.ts +59 -0
- package/src/lib/session-launch.ts +37 -0
- package/src/lib/slash-command.test.ts +44 -0
- package/src/lib/slash-command.ts +45 -0
- package/src/lib/trigger-query.test.ts +94 -0
- package/src/lib/trigger-query.ts +88 -0
- package/src/lib/use-controllable-state.ts +47 -0
- package/src/lib/use-mobile.test.tsx +47 -0
- package/src/lib/use-mobile.ts +34 -12
- package/src/motion.stories.tsx +6 -0
- package/src/templates-object-detail-hub.stories.tsx +2 -2
- package/src/templates-screen-states.stories.tsx +5 -5
- package/src/templates-settings.stories.tsx +1 -1
- package/src/blocks/sidebar-02/app-sidebar.tsx +0 -160
- package/src/blocks/sidebar-02/logo.tsx +0 -18
- package/src/blocks/sidebar-02/nav-main.tsx +0 -13
- package/src/blocks/sidebar-02/nav-notifications.tsx +0 -7
- package/src/blocks/sidebar-02/sidebar-02.stories.tsx +0 -23
- package/src/blocks/sidebar-02/team-switcher.tsx +0 -7
- package/src/blocks/sidebar-04/app-sidebar.tsx +0 -236
- package/src/blocks/sidebar-04/mail-context.tsx +0 -29
- package/src/blocks/sidebar-04/nav-user.tsx +0 -6
- package/src/blocks/sidebar-04/sidebar-04.stories.tsx +0 -150
- package/src/blocks/sidebar-05/app-sidebar.tsx +0 -375
- package/src/blocks/sidebar-05/sidebar-05.stories.tsx +0 -18
- package/src/blocks/sidebar-05/team-switcher.tsx +0 -6
|
@@ -52,6 +52,13 @@ export interface ModelPickerProps extends Omit<HTMLAttributes<HTMLDivElement>, "
|
|
|
52
52
|
open?: boolean;
|
|
53
53
|
/** Fires whenever the popover would open/close — a trigger click, or a row select. */
|
|
54
54
|
onOpenChange?: (open: boolean) => void;
|
|
55
|
+
/**
|
|
56
|
+
* Rendered inside the popover BELOW the list — a persistent action row that is
|
|
57
|
+
* not one of the options (a free-text entry, a "browse…" affordance).
|
|
58
|
+
* Outside `CommandList` on purpose: it must not be filtered by the search query
|
|
59
|
+
* and must not participate in cmdk's roving selection.
|
|
60
|
+
*/
|
|
61
|
+
footer?: ReactNode;
|
|
55
62
|
}
|
|
56
63
|
|
|
57
64
|
function Row({
|
|
@@ -101,9 +108,10 @@ function Row({
|
|
|
101
108
|
* A compact pill that opens a grouped, searchable target list anchored under
|
|
102
109
|
* itself — sized to sit in a composer footer.
|
|
103
110
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
111
|
+
* **Which one to reach for.** An inline pill in a composer footer, anchored
|
|
112
|
+
* under itself → this component. A full ⌘K palette → compose `CommandDialog` +
|
|
113
|
+
* `Command*` from this package directly; there is no separate "selector"
|
|
114
|
+
* component wrapping them.
|
|
107
115
|
*
|
|
108
116
|
* **`Command` lives inside a `Popover`, never a `DropdownMenu`, and that is not
|
|
109
117
|
* a stylistic choice.** `DropdownMenuContent` owns roving tabindex and its own
|
|
@@ -127,6 +135,7 @@ export const ModelPicker = forwardRef<HTMLDivElement, ModelPickerProps>(function
|
|
|
127
135
|
className,
|
|
128
136
|
open: openProp,
|
|
129
137
|
onOpenChange,
|
|
138
|
+
footer,
|
|
130
139
|
...props
|
|
131
140
|
},
|
|
132
141
|
ref,
|
|
@@ -205,37 +214,20 @@ export const ModelPicker = forwardRef<HTMLDivElement, ModelPickerProps>(function
|
|
|
205
214
|
</div>
|
|
206
215
|
) : null}
|
|
207
216
|
|
|
217
|
+
{/*
|
|
218
|
+
#121 — `CommandList` IS the ARIA listbox (cmdk hardcodes
|
|
219
|
+
role="listbox" on it); ARIA only lets a listbox own `option`/
|
|
220
|
+
`group`. It stays MOUNTED in every body (never conditionally
|
|
221
|
+
removed — `CommandInput` hardcodes `aria-controls` to its id,
|
|
222
|
+
and axe's aria-valid-attr-value only tolerates a dangling
|
|
223
|
+
reference when aria-expanded="false"), but every non-option
|
|
224
|
+
body — loading, error/empty, and `CommandEmpty` — renders as a
|
|
225
|
+
SIBLING below it, in the same visual slot an empty `CommandList`
|
|
226
|
+
(which collapses to zero height) would otherwise occupy.
|
|
227
|
+
*/}
|
|
208
228
|
<CommandList>
|
|
209
|
-
{body === "
|
|
210
|
-
|
|
211
|
-
<span className="sr-only" role="status">
|
|
212
|
-
{t("loading")}
|
|
213
|
-
</span>
|
|
214
|
-
<Skeleton className="h-6 w-full" />
|
|
215
|
-
<Skeleton className="h-6 w-4/5" />
|
|
216
|
-
<Skeleton className="h-6 w-3/5" />
|
|
217
|
-
</div>
|
|
218
|
-
) : null}
|
|
219
|
-
|
|
220
|
-
{body === "error" || body === "empty" ? (
|
|
221
|
-
<div className="space-y-2 p-4 text-center" data-slot="model-picker-state">
|
|
222
|
-
<p className="text-body">
|
|
223
|
-
{body === "error"
|
|
224
|
-
? t("ui.modelPicker.loadFailed")
|
|
225
|
-
: t("ui.modelPicker.nothingYet")}
|
|
226
|
-
</p>
|
|
227
|
-
{onRetry ? (
|
|
228
|
-
<Button variant="outline" size="sm" onClick={onRetry}>
|
|
229
|
-
{t("ui.modelPicker.retry")}
|
|
230
|
-
</Button>
|
|
231
|
-
) : null}
|
|
232
|
-
</div>
|
|
233
|
-
) : null}
|
|
234
|
-
|
|
235
|
-
{body === "list" ? (
|
|
236
|
-
<>
|
|
237
|
-
<CommandEmpty>{t("noResults")}</CommandEmpty>
|
|
238
|
-
{groups.map((group) => (
|
|
229
|
+
{body === "list"
|
|
230
|
+
? groups.map((group) => (
|
|
239
231
|
<CommandGroup key={group.key} heading={group.label}>
|
|
240
232
|
{group.items.map((item) => (
|
|
241
233
|
<Row
|
|
@@ -249,10 +241,44 @@ export const ModelPicker = forwardRef<HTMLDivElement, ModelPickerProps>(function
|
|
|
249
241
|
/>
|
|
250
242
|
))}
|
|
251
243
|
</CommandGroup>
|
|
252
|
-
))
|
|
253
|
-
|
|
254
|
-
) : null}
|
|
244
|
+
))
|
|
245
|
+
: null}
|
|
255
246
|
</CommandList>
|
|
247
|
+
|
|
248
|
+
{/*
|
|
249
|
+
cmdk's `Command.Empty` reads `filtered.count` off the `Command`
|
|
250
|
+
root store, not off a `CommandList` ancestor — it renders
|
|
251
|
+
correctly as a sibling of the list it used to nest inside.
|
|
252
|
+
*/}
|
|
253
|
+
{body === "list" ? <CommandEmpty>{t("noResults")}</CommandEmpty> : null}
|
|
254
|
+
|
|
255
|
+
{body === "loading" ? (
|
|
256
|
+
<div className="space-y-2 p-3" data-slot="model-picker-loading">
|
|
257
|
+
<span className="sr-only" role="status">
|
|
258
|
+
{t("loading")}
|
|
259
|
+
</span>
|
|
260
|
+
<Skeleton className="h-6 w-full" />
|
|
261
|
+
<Skeleton className="h-6 w-4/5" />
|
|
262
|
+
<Skeleton className="h-6 w-3/5" />
|
|
263
|
+
</div>
|
|
264
|
+
) : null}
|
|
265
|
+
|
|
266
|
+
{body === "error" || body === "empty" ? (
|
|
267
|
+
<div className="space-y-2 p-4 text-center" data-slot="model-picker-state">
|
|
268
|
+
<p className="text-body">
|
|
269
|
+
{body === "error"
|
|
270
|
+
? t("ui.modelPicker.loadFailed")
|
|
271
|
+
: t("ui.modelPicker.nothingYet")}
|
|
272
|
+
</p>
|
|
273
|
+
{onRetry ? (
|
|
274
|
+
<Button variant="outline" size="sm" onClick={onRetry}>
|
|
275
|
+
{t("ui.modelPicker.retry")}
|
|
276
|
+
</Button>
|
|
277
|
+
) : null}
|
|
278
|
+
</div>
|
|
279
|
+
) : null}
|
|
280
|
+
|
|
281
|
+
{footer}
|
|
256
282
|
</Command>
|
|
257
283
|
</PopoverContent>
|
|
258
284
|
</Popover>
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Shared collapsible sidebar navigation primitive (issue #99).
|
|
3
|
-
* Promoted from `
|
|
5
|
+
* Promoted from `registry/blocks/sidebar-02/nav-main.tsx`.
|
|
4
6
|
*
|
|
5
7
|
* Renders a `SidebarMenu` of routes; each route with sub-items uses a
|
|
6
8
|
* `Collapsible` expand/collapse affordance. Collapses icon-only when the
|
|
@@ -90,7 +92,7 @@ export function NavMain({ routes, className }: NavMainProps) {
|
|
|
90
92
|
>
|
|
91
93
|
{route.icon}
|
|
92
94
|
{!isCollapsed && (
|
|
93
|
-
<span className="ms-2 flex-1 text-
|
|
95
|
+
<span className="ms-2 flex-1 text-body font-medium">{route.title}</span>
|
|
94
96
|
)}
|
|
95
97
|
{!isCollapsed && (
|
|
96
98
|
<span className="ms-auto">
|
|
@@ -114,7 +116,7 @@ export function NavMain({ routes, className }: NavMainProps) {
|
|
|
114
116
|
<SidebarMenuSubButton asChild>
|
|
115
117
|
<a
|
|
116
118
|
href={subRoute.link}
|
|
117
|
-
className="flex items-center rounded-md px-4 py-1.5 text-
|
|
119
|
+
className="flex items-center rounded-md px-4 py-1.5 text-body font-medium text-sidebar-muted-foreground"
|
|
118
120
|
>
|
|
119
121
|
{subRoute.title}
|
|
120
122
|
</a>
|
|
@@ -135,7 +137,9 @@ export function NavMain({ routes, className }: NavMainProps) {
|
|
|
135
137
|
)}
|
|
136
138
|
>
|
|
137
139
|
{route.icon}
|
|
138
|
-
{!isCollapsed &&
|
|
140
|
+
{!isCollapsed && (
|
|
141
|
+
<span className="ms-2 text-body font-medium">{route.title}</span>
|
|
142
|
+
)}
|
|
139
143
|
</a>
|
|
140
144
|
</SidebarMenuButton>
|
|
141
145
|
)}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
3
|
+
import { NavNotifications } from "./nav-notifications";
|
|
4
|
+
|
|
5
|
+
const meta = {
|
|
6
|
+
title: "Navigation/NavNotifications",
|
|
7
|
+
component: NavNotifications,
|
|
8
|
+
tags: ["autodocs"],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'A bell button that opens a short list of recent notifications. Placement is the caller\'s decision, not the component\'s: the defaults (`side="right"`, the `my-6` inset) suit a bell sitting in a left-hand nav rail, while a bell at the end of a top bar passes `side="bottom" align="end"` so the menu hangs below its trigger instead of being collision-flipped back across the controls beside it.',
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
argTypes: {
|
|
18
|
+
notifications: { control: false, table: { category: "Content" } },
|
|
19
|
+
viewAllLabel: { control: "text", table: { category: "Content" } },
|
|
20
|
+
side: { control: "inline-radio", table: { category: "Placement" } },
|
|
21
|
+
align: { control: "inline-radio", table: { category: "Placement" } },
|
|
22
|
+
className: { control: "text", table: { category: "Styling" } },
|
|
23
|
+
},
|
|
24
|
+
} satisfies Meta<typeof NavNotifications>;
|
|
25
|
+
export default meta;
|
|
26
|
+
type Story = StoryObj<typeof meta>;
|
|
27
|
+
|
|
28
|
+
const NOTIFICATIONS = [
|
|
29
|
+
{ id: "n1", fallback: "OR", text: "Order 4821 is waiting on stock.", time: "10m ago" },
|
|
30
|
+
{ id: "n2", fallback: "PY", text: "This week’s payout cleared.", time: "1h ago" },
|
|
31
|
+
{ id: "n3", fallback: "CS", text: "Three customers asked about delivery.", time: "2h ago" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/** The rail placement — the shipped defaults, unchanged. */
|
|
35
|
+
export const Default: Story = {
|
|
36
|
+
args: { notifications: NOTIFICATIONS },
|
|
37
|
+
play: async ({ canvasElement }) => {
|
|
38
|
+
const canvas = within(canvasElement);
|
|
39
|
+
await userEvent.click(canvas.getByRole("button", { name: "Open notifications" }));
|
|
40
|
+
// The menu is portalled to <body>, so it is reached through the document,
|
|
41
|
+
// not the canvas.
|
|
42
|
+
await waitFor(() => expect(document.querySelector('[role="menu"]')).toBeVisible());
|
|
43
|
+
await expect(document.querySelector('[role="menu"]')).toHaveAttribute("data-side", "right");
|
|
44
|
+
// Close it before the story settles. While a Radix menu is open its
|
|
45
|
+
// siblings carry `aria-hidden`, and the trigger inside one of them is
|
|
46
|
+
// still focusable — which axe reports as `aria-hidden-focus` against the
|
|
47
|
+
// ANCESTOR, not against anything this component authored. Leaving the menu
|
|
48
|
+
// open would fail the a11y pass on library behaviour the story is not
|
|
49
|
+
// testing.
|
|
50
|
+
await userEvent.keyboard("{Escape}");
|
|
51
|
+
await waitFor(() => expect(document.querySelector('[role="menu"]')).toBeNull());
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The top-bar placement: `side="bottom" align="end"` puts the menu under the
|
|
57
|
+
* bell and flush with its outer edge. The caller passes NOTHING else — the
|
|
58
|
+
* rail's 24px vertical inset is derived from `side` inside the component, so a
|
|
59
|
+
* bottom-side menu drops it on its own and there is no inset to cancel here.
|
|
60
|
+
* (`className` styles the bell TRIGGER, not the portalled menu, so a `my-0`
|
|
61
|
+
* override would land on the wrong element anyway.)
|
|
62
|
+
*
|
|
63
|
+
* Asserted on Radix's own resolved `data-side`/`data-align`, which report where
|
|
64
|
+
* the menu ACTUALLY landed after collision handling — a class-name assertion
|
|
65
|
+
* would pass on a menu that flipped back across the controls beside it.
|
|
66
|
+
*/
|
|
67
|
+
export const TopBarPlacement: Story = {
|
|
68
|
+
args: {
|
|
69
|
+
notifications: NOTIFICATIONS,
|
|
70
|
+
side: "bottom",
|
|
71
|
+
align: "end",
|
|
72
|
+
},
|
|
73
|
+
play: async ({ canvasElement }) => {
|
|
74
|
+
const canvas = within(canvasElement);
|
|
75
|
+
await userEvent.click(canvas.getByRole("button", { name: "Open notifications" }));
|
|
76
|
+
const content = await waitFor(() => {
|
|
77
|
+
const node = document.querySelector('[role="menu"]');
|
|
78
|
+
expect(node).toBeVisible();
|
|
79
|
+
return node;
|
|
80
|
+
});
|
|
81
|
+
await expect(content).toHaveAttribute("data-side", "bottom");
|
|
82
|
+
await expect(content).toHaveAttribute("data-align", "end");
|
|
83
|
+
// Closed for the same reason as `Default` — see the note there.
|
|
84
|
+
await userEvent.keyboard("{Escape}");
|
|
85
|
+
await waitFor(() => expect(document.querySelector('[role="menu"]')).toBeNull());
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `className` styles the component ROOT — the bell trigger — and merges LAST, so
|
|
91
|
+
* a caller-supplied radius replaces the shipped `rounded-full`.
|
|
92
|
+
*/
|
|
93
|
+
export const CustomTriggerClass: Story = {
|
|
94
|
+
args: { notifications: NOTIFICATIONS, className: "rounded-md" },
|
|
95
|
+
play: async ({ canvasElement }) => {
|
|
96
|
+
const canvas = within(canvasElement);
|
|
97
|
+
const trigger = canvas.getByRole("button", { name: "Open notifications" });
|
|
98
|
+
// WHERE it landed: on the trigger, not on the portalled menu.
|
|
99
|
+
await expect(trigger).toHaveClass("rounded-md");
|
|
100
|
+
// …and it WON. `cn`'s tailwind-merge drops the losing radius outright, so
|
|
101
|
+
// the shipped `rounded-full` is gone from the class list rather than merely
|
|
102
|
+
// ordered behind it.
|
|
103
|
+
await expect(trigger).not.toHaveClass("rounded-full");
|
|
104
|
+
// Asserted on the painted result too, because a class list says nothing
|
|
105
|
+
// about what the browser resolved. `rounded-full` computes to a huge radius
|
|
106
|
+
// (the pill); the token radius is single-digit px.
|
|
107
|
+
const radius = Number.parseFloat(getComputedStyle(trigger).borderTopLeftRadius);
|
|
108
|
+
await expect(radius).toBeLessThan(100);
|
|
109
|
+
},
|
|
110
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import userEvent from "@testing-library/user-event";
|
|
3
4
|
import { NavNotifications } from "./nav-notifications";
|
|
4
5
|
|
|
5
6
|
describe("NavNotifications", () => {
|
|
@@ -12,4 +13,14 @@ describe("NavNotifications", () => {
|
|
|
12
13
|
render(<NavNotifications notifications={notifications} />);
|
|
13
14
|
expect(screen.getByRole("button", { name: "Open notifications" })).toBeInTheDocument();
|
|
14
15
|
});
|
|
16
|
+
|
|
17
|
+
it("caps the menu width so it never overflows a narrow viewport", async () => {
|
|
18
|
+
const user = userEvent.setup();
|
|
19
|
+
render(<NavNotifications notifications={notifications} />);
|
|
20
|
+
await user.click(screen.getByRole("button", { name: "Open notifications" }));
|
|
21
|
+
const menu = await screen.findByRole("menu");
|
|
22
|
+
// Without a max-width cap, the fixed `w-80` (320px) menu forces horizontal
|
|
23
|
+
// overflow on any viewport narrower than ~336px.
|
|
24
|
+
expect(menu.className).toContain("max-w-[calc(100vw-2rem)]");
|
|
25
|
+
});
|
|
15
26
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import type { ComponentProps } from "react";
|
|
3
4
|
import { BellIcon } from "lucide-react";
|
|
4
5
|
import { Avatar, AvatarFallback } from "../avatar";
|
|
5
6
|
import { Button } from "../button";
|
|
@@ -11,6 +12,8 @@ import {
|
|
|
11
12
|
DropdownMenuSeparator,
|
|
12
13
|
DropdownMenuTrigger,
|
|
13
14
|
} from "../dropdown-menu";
|
|
15
|
+
import { useLocale } from "../locale-provider";
|
|
16
|
+
import { cn } from "../../lib/cn";
|
|
14
17
|
|
|
15
18
|
export interface NavNotification {
|
|
16
19
|
id: string;
|
|
@@ -24,30 +27,85 @@ export interface NavNotificationsProps {
|
|
|
24
27
|
notifications: NavNotification[];
|
|
25
28
|
/** Label for the "view all" footer item. @default "View all notifications" */
|
|
26
29
|
viewAllLabel?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Which side of the trigger the menu opens on.
|
|
32
|
+
*
|
|
33
|
+
* The default is `"right"`, which is right for the surface this component was
|
|
34
|
+
* extracted from — a bell sitting in a left-hand nav rail. It is WRONG for a
|
|
35
|
+
* trigger at the end of a top bar: Radix collision-handling then flips the
|
|
36
|
+
* menu back across the trigger and it covers the controls beside it. Set
|
|
37
|
+
* `side="bottom"` (usually with `align="end"`) for a top-bar bell.
|
|
38
|
+
*
|
|
39
|
+
* @default "right"
|
|
40
|
+
*/
|
|
41
|
+
side?: ComponentProps<typeof DropdownMenuContent>["side"];
|
|
42
|
+
/**
|
|
43
|
+
* How the menu aligns along that side. Radix's own default is `"center"`;
|
|
44
|
+
* pass `"end"` so a top-bar menu hangs from the trigger's outer edge instead
|
|
45
|
+
* of straddling it.
|
|
46
|
+
*/
|
|
47
|
+
align?: ComponentProps<typeof DropdownMenuContent>["align"];
|
|
48
|
+
/**
|
|
49
|
+
* Extra classes for the component ROOT — the bell trigger — merged last, so a
|
|
50
|
+
* caller can override the shipped `rounded-full` (`component-api.md`).
|
|
51
|
+
*
|
|
52
|
+
* There is deliberately NO seam for the portalled menu surface: nothing needs
|
|
53
|
+
* one today, and an unused prop is a worse API than a missing one. The one
|
|
54
|
+
* thing a caller used to reach for it (dropping the rail's vertical inset for
|
|
55
|
+
* a top-bar menu) is now derived from `side` instead — see below.
|
|
56
|
+
*/
|
|
57
|
+
className?: string;
|
|
27
58
|
}
|
|
28
59
|
|
|
29
60
|
/**
|
|
30
|
-
*
|
|
31
|
-
* Renders a bell-icon ghost button that opens a dropdown listing recent
|
|
61
|
+
* Notifications dropdown button. Shared primitive from sidebar-02 (issue #99).
|
|
62
|
+
* Renders a bell-icon ghost button that opens a dropdown listing recent
|
|
63
|
+
* notifications.
|
|
64
|
+
*
|
|
65
|
+
* Placement is caller-owned via `side`/`align`; the defaults are the original
|
|
66
|
+
* rail placement, so every existing call site renders unchanged. `className`
|
|
67
|
+
* styles the trigger, not the menu.
|
|
32
68
|
*/
|
|
33
69
|
export function NavNotifications({
|
|
34
70
|
notifications,
|
|
35
71
|
viewAllLabel = "View all notifications",
|
|
72
|
+
side = "right",
|
|
73
|
+
align,
|
|
74
|
+
className,
|
|
36
75
|
}: NavNotificationsProps) {
|
|
76
|
+
const { t } = useLocale();
|
|
37
77
|
return (
|
|
38
78
|
<DropdownMenu>
|
|
39
79
|
<DropdownMenuTrigger asChild>
|
|
40
80
|
<Button
|
|
41
81
|
variant="ghost"
|
|
42
82
|
size="icon"
|
|
43
|
-
className="rounded-full"
|
|
44
|
-
aria-label="
|
|
83
|
+
className={cn("rounded-full", className)}
|
|
84
|
+
aria-label={t("ui.navNotifications.trigger")}
|
|
45
85
|
>
|
|
46
86
|
<BellIcon className="size-5" />
|
|
47
87
|
</Button>
|
|
48
88
|
</DropdownMenuTrigger>
|
|
49
|
-
<DropdownMenuContent
|
|
50
|
-
|
|
89
|
+
<DropdownMenuContent
|
|
90
|
+
side={side}
|
|
91
|
+
align={align}
|
|
92
|
+
// Keeps the menu clear of the viewport edge instead of sitting flush
|
|
93
|
+
// against it on a narrow phone screen.
|
|
94
|
+
collisionPadding={16}
|
|
95
|
+
className={cn(
|
|
96
|
+
// The vertical inset belongs to the SIDE-opening rail placement, where
|
|
97
|
+
// the menu runs alongside its trigger and needs clearance from it. A
|
|
98
|
+
// menu that opens above or below the trigger is already clear of it,
|
|
99
|
+
// and the inset would just hang it 24px off the bar. Derived from
|
|
100
|
+
// `side` rather than exposed as a second className seam.
|
|
101
|
+
(side === "left" || side === "right") && "my-6",
|
|
102
|
+
// `w-80` (320px) is wider than some phone viewports once the 16px
|
|
103
|
+
// collision padding on both sides is subtracted — cap it so the menu
|
|
104
|
+
// shrinks instead of forcing horizontal overflow.
|
|
105
|
+
"w-80 max-w-[calc(100vw-2rem)]",
|
|
106
|
+
)}
|
|
107
|
+
>
|
|
108
|
+
<DropdownMenuLabel>{t("ui.navNotifications.label")}</DropdownMenuLabel>
|
|
51
109
|
<DropdownMenuSeparator />
|
|
52
110
|
{notifications.map(({ id, fallback, text, time }) => (
|
|
53
111
|
<DropdownMenuItem key={id} className="flex items-start gap-3">
|
|
@@ -55,13 +113,13 @@ export function NavNotifications({
|
|
|
55
113
|
<AvatarFallback>{fallback}</AvatarFallback>
|
|
56
114
|
</Avatar>
|
|
57
115
|
<div className="flex flex-col">
|
|
58
|
-
<span className="text-
|
|
59
|
-
<span className="text-
|
|
116
|
+
<span className="text-body font-medium">{text}</span>
|
|
117
|
+
<span className="text-meta text-muted-foreground">{time}</span>
|
|
60
118
|
</div>
|
|
61
119
|
</DropdownMenuItem>
|
|
62
120
|
))}
|
|
63
121
|
<DropdownMenuSeparator />
|
|
64
|
-
<DropdownMenuItem className="justify-center text-
|
|
122
|
+
<DropdownMenuItem className="justify-center text-body text-muted-foreground">
|
|
65
123
|
{viewAllLabel}
|
|
66
124
|
</DropdownMenuItem>
|
|
67
125
|
</DropdownMenuContent>
|
|
@@ -54,9 +54,9 @@ export function NavUser({ user }: NavUserProps) {
|
|
|
54
54
|
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
|
55
55
|
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
|
56
56
|
</Avatar>
|
|
57
|
-
<div className="grid flex-1 text-start text-
|
|
57
|
+
<div className="grid flex-1 text-start text-body leading-tight">
|
|
58
58
|
<span className="truncate font-medium">{user.name}</span>
|
|
59
|
-
<span className="truncate text-
|
|
59
|
+
<span className="truncate text-meta">{user.email}</span>
|
|
60
60
|
</div>
|
|
61
61
|
<ChevronsUpDown className="ms-auto size-4" />
|
|
62
62
|
</SidebarMenuButton>
|
|
@@ -68,14 +68,14 @@ export function NavUser({ user }: NavUserProps) {
|
|
|
68
68
|
sideOffset={4}
|
|
69
69
|
>
|
|
70
70
|
<DropdownMenuLabel className="p-0 font-normal">
|
|
71
|
-
<div className="flex items-center gap-2 px-1 py-1.5 text-start text-
|
|
71
|
+
<div className="flex items-center gap-2 px-1 py-1.5 text-start text-body">
|
|
72
72
|
<Avatar className="h-8 w-8 rounded-lg">
|
|
73
73
|
{user.avatar && <AvatarImage src={user.avatar} alt={user.name} />}
|
|
74
74
|
<AvatarFallback className="rounded-lg">{initials}</AvatarFallback>
|
|
75
75
|
</Avatar>
|
|
76
|
-
<div className="grid flex-1 text-start text-
|
|
76
|
+
<div className="grid flex-1 text-start text-body leading-tight">
|
|
77
77
|
<span className="truncate font-medium">{user.name}</span>
|
|
78
|
-
<span className="truncate text-
|
|
78
|
+
<span className="truncate text-meta">{user.email}</span>
|
|
79
79
|
</div>
|
|
80
80
|
</div>
|
|
81
81
|
</DropdownMenuLabel>
|
|
@@ -86,7 +86,29 @@ export const Default: Story = {
|
|
|
86
86
|
const analytics = await canvas.findByText("Analytics");
|
|
87
87
|
await waitFor(() => expect(analytics).toBeVisible());
|
|
88
88
|
await waitFor(() => expect(canvas.getByText("Data Integration")).toBeVisible());
|
|
89
|
+
// userEvent.click() synthesizes a real pointer sequence (pointerenter +
|
|
90
|
+
// pointermove before the click), which — independently of the click's own
|
|
91
|
+
// open — arms Radix's hover-intent open timer inside
|
|
92
|
+
// @radix-ui/react-navigation-menu's NavigationMenu root (delayDuration,
|
|
93
|
+
// default 200ms). That timer is cleared only by a subsequent pointer
|
|
94
|
+
// leave/enter, never by the click-open path or by Escape's dismiss path,
|
|
95
|
+
// so left alone it fires ~200ms after this interaction regardless of the
|
|
96
|
+
// explicit Escape below — silently reopening the menu well after play()
|
|
97
|
+
// has returned (confirmed via instrumentation: aria-expanded flips back
|
|
98
|
+
// to "true" at ~t+207ms). That reopen is what axe's afterEach scan was
|
|
99
|
+
// intermittently catching — a real defect in the underlying menu, not a
|
|
100
|
+
// wrong wait condition; filed separately (residual, not fixed here).
|
|
101
|
+
// Unhovering cancels that stray timer through Radix's own cancellation
|
|
102
|
+
// path (onTriggerLeave), the same way a real user moving the pointer away
|
|
103
|
+
// before dismissing would.
|
|
104
|
+
await userEvent.unhover(trigger);
|
|
89
105
|
// Escape dismisses the open panel
|
|
90
106
|
await userEvent.keyboard("{Escape}");
|
|
107
|
+
// Wait for the close transition to complete via a real post-condition:
|
|
108
|
+
// aria-expanded flips to "false" in the same React commit that removes
|
|
109
|
+
// the trigger's aria-hidden focus-proxy span (both derive from the same
|
|
110
|
+
// `open` value in NavigationMenuTrigger's render), so this dominates the
|
|
111
|
+
// axe scan that runs immediately after play() resolves.
|
|
112
|
+
await waitFor(() => expect(trigger).toHaveAttribute("aria-expanded", "false"));
|
|
91
113
|
},
|
|
92
114
|
};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { act, cleanup, render, screen } from "@testing-library/react";
|
|
2
|
+
import userEvent from "@testing-library/user-event";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import {
|
|
5
|
+
NavigationMenu,
|
|
6
|
+
NavigationMenuContent,
|
|
7
|
+
NavigationMenuItem,
|
|
8
|
+
NavigationMenuLink,
|
|
9
|
+
NavigationMenuList,
|
|
10
|
+
NavigationMenuTrigger,
|
|
11
|
+
} from "./navigation-menu";
|
|
12
|
+
|
|
13
|
+
afterEach(cleanup);
|
|
14
|
+
|
|
15
|
+
function renderMenu() {
|
|
16
|
+
render(
|
|
17
|
+
<NavigationMenu>
|
|
18
|
+
<NavigationMenuList>
|
|
19
|
+
<NavigationMenuItem>
|
|
20
|
+
<NavigationMenuTrigger>Products</NavigationMenuTrigger>
|
|
21
|
+
<NavigationMenuContent>
|
|
22
|
+
<NavigationMenuLink href="#">Analytics</NavigationMenuLink>
|
|
23
|
+
</NavigationMenuContent>
|
|
24
|
+
</NavigationMenuItem>
|
|
25
|
+
</NavigationMenuList>
|
|
26
|
+
</NavigationMenu>,
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("NavigationMenuTrigger", () => {
|
|
31
|
+
/**
|
|
32
|
+
* Residual of #54 (issue #85). `userEvent.click()` — like a real mouse click
|
|
33
|
+
* — synthesizes a pointer move onto the trigger before the click itself,
|
|
34
|
+
* which arms `@radix-ui/react-navigation-menu`'s root-level hover-intent
|
|
35
|
+
* open timer (`openTimerRef`, default `delayDuration` 200ms). Neither the
|
|
36
|
+
* click-open path (`onItemSelect`) nor any dismiss path (Escape /
|
|
37
|
+
* outside-click / focus-out, all funnelled through one
|
|
38
|
+
* `ROOT_CONTENT_DISMISS` handler) clears that timer, so — left unpatched —
|
|
39
|
+
* it fires ~200ms later and silently reopens the panel even though the user
|
|
40
|
+
* already dismissed it.
|
|
41
|
+
*
|
|
42
|
+
* Fake timers are load-bearing here, not incidental: asserting
|
|
43
|
+
* `aria-expanded` immediately after Escape passes on the UNPATCHED wrapper
|
|
44
|
+
* too, because the stray timer has not fired yet. The lock only means
|
|
45
|
+
* anything once the assertion runs from INSIDE the delay window.
|
|
46
|
+
*/
|
|
47
|
+
it("does not spontaneously reopen after Escape dismisses it inside the hover-intent delay window", async () => {
|
|
48
|
+
// `shouldAdvanceTime` keeps the fake clock ticking in step with real time
|
|
49
|
+
// (in addition to responding to explicit `advanceTimersByTime` calls)
|
|
50
|
+
// so React's own scheduler — which falls back to a real `setTimeout` for
|
|
51
|
+
// its internal flush in this jsdom environment — is never left waiting
|
|
52
|
+
// on a fake timer nothing ever advances; plain `vi.useFakeTimers()` here
|
|
53
|
+
// hangs `userEvent.click()` indefinitely for exactly that reason.
|
|
54
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
55
|
+
try {
|
|
56
|
+
const user = userEvent.setup({ delay: null, advanceTimers: vi.advanceTimersByTime });
|
|
57
|
+
renderMenu();
|
|
58
|
+
const trigger = screen.getByRole("button", { name: /products/i });
|
|
59
|
+
|
|
60
|
+
// Deliberately the opposite of #54's story-level unhover() workaround:
|
|
61
|
+
// hover-then-click with NO intervening pointer-leave, so the real
|
|
62
|
+
// click-then-dismiss sequence is exercised instead of routed around it.
|
|
63
|
+
await user.click(trigger);
|
|
64
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
65
|
+
|
|
66
|
+
await user.keyboard("{Escape}");
|
|
67
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
68
|
+
|
|
69
|
+
// Advance past delayDuration (200ms) + a margin so the stray timer, if
|
|
70
|
+
// still armed, fires.
|
|
71
|
+
await act(async () => {
|
|
72
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
76
|
+
} finally {
|
|
77
|
+
vi.useRealTimers();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Acceptance criterion from #85: the fix must not regress ordinary
|
|
83
|
+
* click-to-toggle behaviour into the "menu never opens" failure mode hit by
|
|
84
|
+
* an earlier, abandoned `delayDuration={0}` attempt. Several rapid
|
|
85
|
+
* open/close/open cycles, entirely via click (no hover), must all still
|
|
86
|
+
* land the menu in the expected state.
|
|
87
|
+
*/
|
|
88
|
+
it("still opens and closes normally across rapid repeated click cycles", async () => {
|
|
89
|
+
const user = userEvent.setup();
|
|
90
|
+
renderMenu();
|
|
91
|
+
const trigger = screen.getByRole("button", { name: /products/i });
|
|
92
|
+
|
|
93
|
+
for (let cycle = 0; cycle < 3; cycle += 1) {
|
|
94
|
+
await user.click(trigger);
|
|
95
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
96
|
+
|
|
97
|
+
await user.click(trigger);
|
|
98
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Fix-round-1 regression (validator finding): a synthetic pointer-leave
|
|
104
|
+
* dispatched on every click — the first attempt at fixing #85 — runs
|
|
105
|
+
* Radix's real `onTriggerLeave`, which does two things, not one: it clears
|
|
106
|
+
* the stray open timer (wanted) AND unconditionally arms Radix's 150ms
|
|
107
|
+
* close timer (`startCloseTimer`/`closeTimerRef`, unwanted) via
|
|
108
|
+
* `context.onTriggerLeave` (`@radix-ui/react-navigation-menu@1.2.14 and
|
|
109
|
+
* 1.2.22`'s index.mjs, root `onTriggerLeave`). Since a click-opened menu
|
|
110
|
+
* never gets a real pointerenter on its content, nothing ever clears that
|
|
111
|
+
* close timer, so it silently closes itself ~150ms after every click-open
|
|
112
|
+
* unless the mouse happens to move onto the panel — breaking keyboard and
|
|
113
|
+
* touch entry entirely, and any mouse user who pauses before reaching the
|
|
114
|
+
* panel. This test opens the menu via click and asserts it is STILL OPEN
|
|
115
|
+
* well past that delay with no further pointer interaction at all.
|
|
116
|
+
*/
|
|
117
|
+
it("stays open past the close-timer delay when opened by click with no further pointer movement", async () => {
|
|
118
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
119
|
+
try {
|
|
120
|
+
const user = userEvent.setup({ delay: null, advanceTimers: vi.advanceTimersByTime });
|
|
121
|
+
renderMenu();
|
|
122
|
+
const trigger = screen.getByRole("button", { name: /products/i });
|
|
123
|
+
|
|
124
|
+
await user.click(trigger);
|
|
125
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
126
|
+
|
|
127
|
+
// Advance well past both Radix's 150ms close-timer delay and its
|
|
128
|
+
// 200ms open-timer delay, with no further interaction of any kind.
|
|
129
|
+
await act(async () => {
|
|
130
|
+
await vi.advanceTimersByTimeAsync(400);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
134
|
+
} finally {
|
|
135
|
+
vi.useRealTimers();
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Fix-round-1 requirement: the guard against #85's stray reopen must be
|
|
141
|
+
* scoped to genuine, still-hovering pointer dismissals — it must never
|
|
142
|
+
* interfere with a pure keyboard sequence (no mouse involved at all).
|
|
143
|
+
* Opening and dismissing via keyboard alone must behave exactly as an
|
|
144
|
+
* unpatched menu would: the panel closes on Escape, focus returns to the
|
|
145
|
+
* trigger (Radix's own `triggerRef.current?.focus()` on dismiss), and
|
|
146
|
+
* nothing reopens it afterward.
|
|
147
|
+
*/
|
|
148
|
+
it("keyboard: Escape dismisses, returns focus to the trigger, and does not reopen", async () => {
|
|
149
|
+
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
150
|
+
try {
|
|
151
|
+
const user = userEvent.setup({ delay: null, advanceTimers: vi.advanceTimersByTime });
|
|
152
|
+
renderMenu();
|
|
153
|
+
const trigger = screen.getByRole("button", { name: /products/i });
|
|
154
|
+
|
|
155
|
+
act(() => {
|
|
156
|
+
trigger.focus();
|
|
157
|
+
});
|
|
158
|
+
await user.keyboard("{Enter}");
|
|
159
|
+
expect(trigger).toHaveAttribute("aria-expanded", "true");
|
|
160
|
+
|
|
161
|
+
await user.keyboard("{Escape}");
|
|
162
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
163
|
+
expect(trigger).toHaveFocus();
|
|
164
|
+
|
|
165
|
+
await act(async () => {
|
|
166
|
+
await vi.advanceTimersByTimeAsync(300);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
expect(trigger).toHaveAttribute("aria-expanded", "false");
|
|
170
|
+
} finally {
|
|
171
|
+
vi.useRealTimers();
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
});
|