@elabs-ai/components-ui 4.0.0 → 4.1.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-JKPPTL63.js +48 -0
- package/dist/chunk-JKPPTL63.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 +2485 -167
- package/dist/index.js +10716 -2706
- package/dist/index.js.map +1 -1
- package/package.json +21 -7
- package/src/_peer-deps.test.ts +59 -0
- package/src/blocks-comparison-table.stories.tsx +2 -2
- package/src/components/accordion/accordion.tsx +1 -1
- package/src/components/advanced-group/advanced-group.tsx +1 -1
- package/src/components/alert/alert.tsx +4 -1
- package/src/components/app-shell/app-shell.stories.tsx +44 -2
- package/src/components/app-shell/app-shell.tsx +35 -2
- package/src/components/app-sidebar/app-sidebar.stories.tsx +9 -1
- package/src/components/attribution-panel/attribution-panel.tsx +1 -1
- package/src/components/attribution-panel/attributions.generated.ts +81 -77
- package/src/components/badge/badge.stories.tsx +8 -0
- package/src/components/badge/badge.tsx +1 -1
- package/src/components/bento-grid/bento-grid.test.tsx +1 -1
- package/src/components/bento-grid/bento-grid.tsx +1 -1
- package/src/components/breadcrumb/breadcrumb.stories.tsx +38 -0
- package/src/components/breadcrumb/breadcrumb.test.tsx +24 -1
- package/src/components/breadcrumb/breadcrumb.tsx +7 -2
- package/src/components/button/button.stories.tsx +116 -8
- package/src/components/button/button.tsx +1 -1
- package/src/components/card/card.stories.tsx +11 -6
- package/src/components/card/card.tsx +1 -1
- 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 +185 -4
- package/src/components/checkbox/checkbox.tsx +1 -1
- package/src/components/color-picker/color-picker.tsx +6 -5
- package/src/components/command/command.stories.tsx +16 -6
- package/src/components/command/command.test.tsx +73 -0
- package/src/components/command/command.tsx +30 -7
- 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-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.tsx +1 -1
- package/src/components/dialog/dialog.tsx +2 -2
- package/src/components/dropdown-menu/dropdown-menu.stories.tsx +1 -1
- 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 +340 -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 +48 -6
- package/src/components/file-upload/file-upload.stories.tsx +16 -0
- package/src/components/file-upload/file-upload.test.tsx +7 -2
- package/src/components/file-upload/file-upload.tsx +15 -4
- package/src/components/form/form.tsx +5 -2
- package/src/components/icon-button/icon-button.test.tsx +1 -1
- package/src/components/icon-button/icon-button.tsx +2 -2
- package/src/components/input/input.stories.tsx +121 -0
- package/src/components/input/input.test.tsx +13 -0
- package/src/components/input/input.tsx +2 -2
- package/src/components/input-group/input-group.stories.tsx +74 -0
- package/src/components/input-group/input-group.tsx +13 -4
- package/src/components/input-otp/input-otp.tsx +5 -1
- 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/link-preview/link-preview.tsx +1 -1
- package/src/components/locale-provider/index.ts +6 -1
- 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 +501 -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.tsx +8 -38
- package/src/components/mention-input/mention-value.ts +8 -2
- 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 +1 -1
- package/src/components/nav-notifications/nav-notifications.stories.tsx +110 -0
- package/src/components/nav-notifications/nav-notifications.tsx +57 -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 +189 -12
- 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 +90 -0
- package/src/components/pagination/pagination.tsx +9 -5
- package/src/components/radio-group/radio-group.tsx +1 -1
- package/src/components/rating/rating.tsx +1 -1
- package/src/components/resizable/resizable.stories.tsx +114 -2
- package/src/components/resizable/resizable.tsx +1 -1
- 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 +46 -3
- 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.stories.tsx +566 -0
- package/src/components/schema-form/schema-form.test.tsx +900 -0
- package/src/components/schema-form/schema-form.tsx +1545 -0
- package/src/components/section-header/section-header.stories.tsx +37 -0
- package/src/components/section-header/section-header.tsx +12 -1
- package/src/components/select/select.tsx +6 -2
- package/src/components/sheet/sheet.tsx +1 -1
- 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 +273 -0
- package/src/components/side-dock/side-dock.tsx +502 -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 +36 -1
- package/src/components/sidebar/sidebar.tsx +245 -33
- 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/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 +96 -17
- package/src/components/status-badge/status-badge.stories.tsx +8 -0
- package/src/components/switch/switch.tsx +1 -1
- package/src/components/table/table.stories.tsx +62 -0
- package/src/components/table/table.test.tsx +142 -1
- package/src/components/table/table.tsx +60 -1
- package/src/components/tabs/tabs.tsx +8 -2
- package/src/components/tag-input/tag-input.tsx +3 -2
- package/src/components/team-switcher/team-switcher.tsx +34 -5
- package/src/components/textarea/textarea.tsx +1 -1
- package/src/components/theme-switcher/theme-switcher.tsx +6 -2
- 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 +11 -1
- package/src/components/toggle/toggle.tsx +1 -1
- package/src/components/toolbar/toolbar.stories.tsx +10 -2
- package/src/components/tree/tree.stories.tsx +2 -2
- package/src/components/tree/tree.tsx +14 -9
- package/src/components/typography/prose.tsx +1 -1
- 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.test.tsx +1 -1
- package/src/components/wizard/wizard.tsx +1 -1
- 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 +75 -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/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 +92 -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/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-mobile.ts +12 -6
- 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
|
@@ -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,79 @@ 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"
|
|
83
|
+
className={cn("rounded-full", className)}
|
|
44
84
|
aria-label="Open notifications"
|
|
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
|
+
className={cn(
|
|
93
|
+
// The vertical inset belongs to the SIDE-opening rail placement, where
|
|
94
|
+
// the menu runs alongside its trigger and needs clearance from it. A
|
|
95
|
+
// menu that opens above or below the trigger is already clear of it,
|
|
96
|
+
// and the inset would just hang it 24px off the bar. Derived from
|
|
97
|
+
// `side` rather than exposed as a second className seam.
|
|
98
|
+
(side === "left" || side === "right") && "my-6",
|
|
99
|
+
"w-80",
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
102
|
+
<DropdownMenuLabel>{t("ui.navNotifications.label")}</DropdownMenuLabel>
|
|
51
103
|
<DropdownMenuSeparator />
|
|
52
104
|
{notifications.map(({ id, fallback, text, time }) => (
|
|
53
105
|
<DropdownMenuItem key={id} className="flex items-start gap-3">
|
|
@@ -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
|
+
});
|
|
@@ -1,22 +1,164 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
forwardRef,
|
|
4
|
+
useContext,
|
|
5
|
+
useId,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ComponentPropsWithoutRef,
|
|
10
|
+
type ElementRef,
|
|
11
|
+
type PointerEvent as ReactPointerEvent,
|
|
12
|
+
} from "react";
|
|
2
13
|
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu";
|
|
3
14
|
import { cva } from "class-variance-authority";
|
|
4
15
|
import { ChevronDown } from "lucide-react";
|
|
5
16
|
import { cn } from "../../lib/cn";
|
|
6
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Residual of #54 (issue #85, fix round 1 → round 2): a pointer move onto a
|
|
20
|
+
* trigger arms `@radix-ui/react-navigation-menu`'s root-level hover-intent
|
|
21
|
+
* open timer (`openTimerRef`, default `delayDuration` 200ms — confirmed
|
|
22
|
+
* unchanged through `@radix-ui/react-navigation-menu@1.2.22`, the current
|
|
23
|
+
* latest stable). Neither the click-open path (root `onItemSelect`) nor any
|
|
24
|
+
* dismiss path (Escape, outside-click, focus-out — all funnelled through one
|
|
25
|
+
* `ROOT_CONTENT_DISMISS` handler → `onItemDismiss`) clears that timer, so it
|
|
26
|
+
* fires later and silently reopens the panel even after an explicit
|
|
27
|
+
* dismissal.
|
|
28
|
+
*
|
|
29
|
+
* Round 1 ran Radix's own `onTriggerLeave` cancellation path (a synthetic
|
|
30
|
+
* `pointerout`) on every click. That clears the stray open timer, but
|
|
31
|
+
* `onTriggerLeave` does two things, not one — it ALSO unconditionally arms
|
|
32
|
+
* Radix's 150ms close timer (`startCloseTimer`/`closeTimerRef`), which
|
|
33
|
+
* nothing then cancels for a click-opened (or keyboard/touch-opened) menu,
|
|
34
|
+
* since it never gets a real pointerenter on its content. Every click-opened
|
|
35
|
+
* menu silently closed itself ~150ms later. No supported, finer-grained Radix
|
|
36
|
+
* API exists to clear only the open timer — the 6 context callbacks
|
|
37
|
+
* (`onTriggerEnter`/`onTriggerLeave`/`onContentEnter`/`onContentLeave`/
|
|
38
|
+
* `onItemSelect`/`onItemDismiss`) are the only reachable surface, and none of
|
|
39
|
+
* the internal refs (`openTimerRef`, `closeTimerRef`, `wasClickCloseRef`,
|
|
40
|
+
* `wasEscapeCloseRef`) are exported.
|
|
41
|
+
*
|
|
42
|
+
* Round 2 fix: stop reaching into Radix's internal timers via synthetic
|
|
43
|
+
* events entirely. `NavigationMenu` takes over `value`/`onValueChange` as a
|
|
44
|
+
* controlled proxy (transparent to a consumer's own controlled/uncontrolled
|
|
45
|
+
* usage) and rejects, ONCE, a reopen request for an item that was just
|
|
46
|
+
* dismissed (value → "") while a REAL mouse pointer was still resting on its
|
|
47
|
+
* trigger — exactly the #85 scenario (dismissed inside the hover-intent
|
|
48
|
+
* window, pointer never left). The guard is a one-shot, pointer-scoped
|
|
49
|
+
* suppression per item (`suppressReopenForRef` in `NavigationMenu`, armed by
|
|
50
|
+
* `NavigationMenuItemValueContext` + a small guard context), cleared the
|
|
51
|
+
* instant ANY of the following happens, so it can never block a genuine
|
|
52
|
+
* interaction: (a) the real pointer leaves that trigger (a fresh hover-intent
|
|
53
|
+
* cycle is legitimate — Radix's own `wasEscapeCloseRef`/`wasClickCloseRef`
|
|
54
|
+
* guards reset on the same real pointerenter), or (b) any click on that
|
|
55
|
+
* trigger (`noteExplicitActivation`, which runs synchronously before Radix's
|
|
56
|
+
* own `onItemSelect` in the same click dispatch, per
|
|
57
|
+
* `composeEventHandlers(props.onClick, () => context.onItemSelect(...))` in
|
|
58
|
+
* `@radix-ui/react-navigation-menu`'s Trigger). A pure keyboard sequence never
|
|
59
|
+
* touches this guard at all (no pointer event ever marks the trigger as
|
|
60
|
+
* "resting"), so it cannot regress keyboard or touch entry — see the 3
|
|
61
|
+
* scenarios locked in `navigation-menu.test.tsx` (pointer dismiss stays
|
|
62
|
+
* dismissed, click-open survives past the close-timer delay with no pointer
|
|
63
|
+
* movement, keyboard open/Escape/no-reopen).
|
|
64
|
+
*
|
|
65
|
+
* `NavigationMenuItem` is wrapped (was a bare re-export) purely so its
|
|
66
|
+
* resolved item value — the same value Radix's OWN auto-`useId()` fallback
|
|
67
|
+
* would otherwise compute internally and never expose — is available to
|
|
68
|
+
* `NavigationMenuTrigger` without inventing a new public prop or reaching
|
|
69
|
+
* into Radix's non-exported item context.
|
|
70
|
+
*
|
|
71
|
+
* Tracked upstream: https://github.com/radix-ui/primitives/issues (file and
|
|
72
|
+
* link the specific issue here once opened — non-blocking, parallel to this
|
|
73
|
+
* interim fix; confirmed still present in 1.2.22, the latest stable release
|
|
74
|
+
* as of this fix).
|
|
75
|
+
*/
|
|
76
|
+
type NavigationMenuGuard = {
|
|
77
|
+
notePointerPresence(itemValue: string, present: boolean): void;
|
|
78
|
+
noteExplicitActivation(itemValue: string): void;
|
|
79
|
+
/** One-shot: returns true (and clears the flag) iff this reopen should be swallowed. */
|
|
80
|
+
consumeSuppressionFlag(itemValue: string): boolean;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const NavigationMenuGuardContext = createContext<NavigationMenuGuard | null>(null);
|
|
84
|
+
const NavigationMenuItemValueContext = createContext<string | undefined>(undefined);
|
|
85
|
+
|
|
7
86
|
export const NavigationMenu = forwardRef<
|
|
8
87
|
ElementRef<typeof NavigationMenuPrimitive.Root>,
|
|
9
88
|
ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Root>
|
|
10
|
-
>(function NavigationMenu(
|
|
89
|
+
>(function NavigationMenu(
|
|
90
|
+
{ className, children, value: valueProp, defaultValue, onValueChange, ...props },
|
|
91
|
+
ref,
|
|
92
|
+
) {
|
|
93
|
+
const isControlled = valueProp !== undefined;
|
|
94
|
+
const [internalValue, setInternalValue] = useState(defaultValue ?? "");
|
|
95
|
+
const currentValue = isControlled ? (valueProp as string) : internalValue;
|
|
96
|
+
const currentValueRef = useRef(currentValue);
|
|
97
|
+
currentValueRef.current = currentValue;
|
|
98
|
+
|
|
99
|
+
// itemValue currently under a resting, real (mouse) pointer — at most one
|
|
100
|
+
// at a time in practice, since a real pointer can only be inside one
|
|
101
|
+
// trigger's bounds.
|
|
102
|
+
const pointerPresentForRef = useRef<string | null>(null);
|
|
103
|
+
// One-shot: the itemValue whose next reopen request should be swallowed.
|
|
104
|
+
const suppressReopenForRef = useRef<string | null>(null);
|
|
105
|
+
|
|
106
|
+
const guard = useMemo<NavigationMenuGuard>(
|
|
107
|
+
() => ({
|
|
108
|
+
notePointerPresence(itemValue, present) {
|
|
109
|
+
if (present) {
|
|
110
|
+
pointerPresentForRef.current = itemValue;
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
if (pointerPresentForRef.current === itemValue) pointerPresentForRef.current = null;
|
|
114
|
+
// A genuine leave ends the suspicious window this guard exists for —
|
|
115
|
+
// any future reopen must come from a fresh interaction (a new
|
|
116
|
+
// hover-intent cycle after re-entering, or a click), both legitimate.
|
|
117
|
+
if (suppressReopenForRef.current === itemValue) suppressReopenForRef.current = null;
|
|
118
|
+
},
|
|
119
|
+
noteExplicitActivation(itemValue) {
|
|
120
|
+
if (suppressReopenForRef.current === itemValue) suppressReopenForRef.current = null;
|
|
121
|
+
},
|
|
122
|
+
consumeSuppressionFlag(itemValue) {
|
|
123
|
+
if (suppressReopenForRef.current === itemValue) {
|
|
124
|
+
suppressReopenForRef.current = null;
|
|
125
|
+
return true;
|
|
126
|
+
}
|
|
127
|
+
return false;
|
|
128
|
+
},
|
|
129
|
+
}),
|
|
130
|
+
[],
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const handleValueChange = (next: string) => {
|
|
134
|
+
const previous = currentValueRef.current;
|
|
135
|
+
if (next === "") {
|
|
136
|
+
if (previous !== "" && pointerPresentForRef.current === previous) {
|
|
137
|
+
// Dismissed while a real pointer still rests on its trigger, inside
|
|
138
|
+
// the hover-intent window — the #85 defect. Arm the one-shot guard
|
|
139
|
+
// against exactly this item's next reopen request.
|
|
140
|
+
suppressReopenForRef.current = previous;
|
|
141
|
+
}
|
|
142
|
+
} else if (guard.consumeSuppressionFlag(next)) {
|
|
143
|
+
return; // Swallow the stray reopen — stay on `previous` (closed).
|
|
144
|
+
}
|
|
145
|
+
if (!isControlled) setInternalValue(next);
|
|
146
|
+
onValueChange?.(next);
|
|
147
|
+
};
|
|
148
|
+
|
|
11
149
|
return (
|
|
12
|
-
<
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
150
|
+
<NavigationMenuGuardContext.Provider value={guard}>
|
|
151
|
+
<NavigationMenuPrimitive.Root
|
|
152
|
+
ref={ref}
|
|
153
|
+
value={currentValue}
|
|
154
|
+
onValueChange={handleValueChange}
|
|
155
|
+
className={cn("relative z-10 flex max-w-max flex-1 items-center justify-center", className)}
|
|
156
|
+
{...props}
|
|
157
|
+
>
|
|
158
|
+
{children}
|
|
159
|
+
<NavigationMenuViewport />
|
|
160
|
+
</NavigationMenuPrimitive.Root>
|
|
161
|
+
</NavigationMenuGuardContext.Provider>
|
|
20
162
|
);
|
|
21
163
|
});
|
|
22
164
|
|
|
@@ -33,7 +175,18 @@ export const NavigationMenuList = forwardRef<
|
|
|
33
175
|
);
|
|
34
176
|
});
|
|
35
177
|
|
|
36
|
-
export const NavigationMenuItem =
|
|
178
|
+
export const NavigationMenuItem = forwardRef<
|
|
179
|
+
ElementRef<typeof NavigationMenuPrimitive.Item>,
|
|
180
|
+
ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Item>
|
|
181
|
+
>(function NavigationMenuItem({ value: valueProp, ...props }, ref) {
|
|
182
|
+
const autoValue = useId();
|
|
183
|
+
const value = valueProp ?? autoValue;
|
|
184
|
+
return (
|
|
185
|
+
<NavigationMenuItemValueContext.Provider value={value}>
|
|
186
|
+
<NavigationMenuPrimitive.Item ref={ref} value={value} {...props} />
|
|
187
|
+
</NavigationMenuItemValueContext.Provider>
|
|
188
|
+
);
|
|
189
|
+
});
|
|
37
190
|
|
|
38
191
|
export const navigationMenuTriggerStyle = cva(
|
|
39
192
|
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent",
|
|
@@ -42,11 +195,35 @@ export const navigationMenuTriggerStyle = cva(
|
|
|
42
195
|
export const NavigationMenuTrigger = forwardRef<
|
|
43
196
|
ElementRef<typeof NavigationMenuPrimitive.Trigger>,
|
|
44
197
|
ComponentPropsWithoutRef<typeof NavigationMenuPrimitive.Trigger>
|
|
45
|
-
>(function NavigationMenuTrigger(
|
|
198
|
+
>(function NavigationMenuTrigger(
|
|
199
|
+
{ className, children, onClick, onPointerEnter, onPointerLeave, ...props },
|
|
200
|
+
ref,
|
|
201
|
+
) {
|
|
202
|
+
const itemValue = useContext(NavigationMenuItemValueContext);
|
|
203
|
+
const guard = useContext(NavigationMenuGuardContext);
|
|
204
|
+
|
|
205
|
+
const notePointer = (event: ReactPointerEvent<HTMLButtonElement>, present: boolean) => {
|
|
206
|
+
if (itemValue !== undefined && guard && event.pointerType === "mouse") {
|
|
207
|
+
guard.notePointerPresence(itemValue, present);
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
|
|
46
211
|
return (
|
|
47
212
|
<NavigationMenuPrimitive.Trigger
|
|
48
213
|
ref={ref}
|
|
49
214
|
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
|
215
|
+
onPointerEnter={(event) => {
|
|
216
|
+
onPointerEnter?.(event);
|
|
217
|
+
notePointer(event, true);
|
|
218
|
+
}}
|
|
219
|
+
onPointerLeave={(event) => {
|
|
220
|
+
onPointerLeave?.(event);
|
|
221
|
+
notePointer(event, false);
|
|
222
|
+
}}
|
|
223
|
+
onClick={(event) => {
|
|
224
|
+
if (itemValue !== undefined) guard?.noteExplicitActivation(itemValue);
|
|
225
|
+
onClick?.(event);
|
|
226
|
+
}}
|
|
50
227
|
{...props}
|
|
51
228
|
>
|
|
52
229
|
{children}
|