Skip to content

Commit bb96f01

Browse files
committed
feat(ui): add Radix overlays themed to VS Code
Tooltip, ContextMenu, and DropdownMenu wrap the Radix primitives with flat compound exports (Tooltip takes a content prop) and are styled to match the native menu and hover widgets via --ui-* tokens, including high contrast, forced-colors, and reduced-motion handling. Long menus and tooltips cap to the space Radix reports and scroll like native. Stories snapshot the four theme families through Chromatic and assert focus handling in play functions.
1 parent b9d3bea commit bb96f01

20 files changed

Lines changed: 1669 additions & 12 deletions

.storybook/global.css

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@ html {
22
background: var(--vscode-sideBar-background);
33
}
44

5+
/* Typography lives on body like real webviews, so portalled content
6+
(menus, tooltips) inherits it too. */
57
body {
8+
color: var(--vscode-editor-foreground);
9+
font-family: var(--vscode-font-family);
10+
font-weight: var(--vscode-font-weight);
11+
font-size: var(--vscode-font-size);
612
margin: 0;
713
padding: 0;
814
overflow: hidden;
@@ -15,10 +21,6 @@ body {
1521
#root {
1622
overscroll-behavior-x: none;
1723
background-color: var(--vscode-sideBar-background);
18-
color: var(--vscode-editor-foreground);
19-
font-family: var(--vscode-font-family);
20-
font-weight: var(--vscode-font-weight);
21-
font-size: var(--vscode-font-size);
2224
margin: 0;
2325
max-width: 100%;
2426
/* arbitrary size choice for the rough VSCode sidebar size */

eslint.config.mjs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,25 @@ export default defineConfig(
208208
},
209209
},
210210

211+
// Keep the UI package independent from other workspace packages.
212+
{
213+
files: ["packages/ui/**/*.{ts,tsx}"],
214+
rules: {
215+
"import-x/no-relative-packages": "error",
216+
"no-restricted-imports": [
217+
"error",
218+
{
219+
patterns: [
220+
{
221+
group: ["@repo/*"],
222+
message: "packages/ui must not import other workspace packages.",
223+
},
224+
],
225+
},
226+
],
227+
},
228+
},
229+
211230
// React rules with type-checked analysis (covers hooks, JSX, DOM)
212231
{
213232
files: ["packages/**/*.{ts,tsx}"],

packages/ui/README.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,37 @@ import "@repo/ui/tokens.css";
2323

2424
`codicon.css` re-exports the codicon font and classes.
2525

26+
## Overlays
27+
28+
`Tooltip`, `ContextMenu`, and `DropdownMenu` wrap the Radix primitives and
29+
are styled to match the native VS Code menu and hover widgets. The menus
30+
expose Radix's compound parts as flat named exports
31+
(`DropdownMenuTrigger`, `DropdownMenuItem`, …); `Tooltip` is a single
32+
component taking a `content` prop, with a 500ms show delay matching VS
33+
Code's `workbench.hover.delay` default. Each `Tooltip` mounts its own
34+
Radix provider, so the cross-trigger skip-delay window is not shared
35+
between tooltips; if that matters, expose a shared provider. Checkbox/
36+
radio items, labels, and keybinding hints are not wrapped yet.
37+
38+
Overlay content is portalled to `body` and inherits webview typography
39+
from there; surface colors come from the `--ui-menu-*` and
40+
`--ui-tooltip-*` tokens. High contrast follows the VS Code contrast
41+
variables, and the styles handle `forced-colors` and
42+
`prefers-reduced-motion`.
43+
2644
## Rules
2745

2846
The package is shaped for a future standalone NPM split. Keep it that way:
2947

3048
- No `workspace:*` runtime dependencies (no `@repo/shared`,
31-
`@repo/webview-shared`).
32-
- `react` stays a peer dependency.
49+
`@repo/webview-shared`); ESLint rejects `@repo/*` imports and relative
50+
cross-package imports in this package.
51+
- `react` stays a peer dependency; the Radix overlay primitives are the
52+
only other runtime dependencies.
3353
- New entry points go in the `exports` map; consumers never deep-import
3454
`src/`.
55+
- Shared internals are reached through `package.json` subpath imports
56+
(`#cx`, `#storybook`). These resolve only inside this package and ship
57+
with it, so they survive a standalone NPM split.
3558
- New components define their VS Code mappings in `tokens.css`, not
3659
inline `--vscode-*` references.

packages/ui/package.json

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
"description": "Shared component library for VS Code webviews",
55
"private": true,
66
"type": "module",
7+
"sideEffects": [
8+
"**/*.css",
9+
"./storybook.preview.ts"
10+
],
711
"exports": {
812
".": {
913
"types": "./src/index.ts",
@@ -12,10 +16,17 @@
1216
"./codicon.css": "./src/codicon.css",
1317
"./tokens.css": "./src/tokens.css"
1418
},
19+
"imports": {
20+
"#cx": "./src/cx.ts",
21+
"#storybook": "./src/storybook.ts"
22+
},
1523
"scripts": {
1624
"typecheck": "tsc --noEmit"
1725
},
1826
"dependencies": {
27+
"@radix-ui/react-context-menu": "catalog:",
28+
"@radix-ui/react-dropdown-menu": "catalog:",
29+
"@radix-ui/react-tooltip": "catalog:",
1930
"@vscode/codicons": "catalog:"
2031
},
2132
"peerDependencies": {
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { expect, screen, userEvent, waitFor, within } from "storybook/test";
2+
3+
import { FOUR_THEME_MODES } from "#storybook";
4+
5+
import {
6+
ContextMenu,
7+
ContextMenuContent,
8+
ContextMenuItem,
9+
ContextMenuSeparator,
10+
ContextMenuSub,
11+
ContextMenuSubContent,
12+
ContextMenuSubTrigger,
13+
ContextMenuTrigger,
14+
} from "./ContextMenu";
15+
16+
import type { Meta, StoryObj } from "@storybook/react-vite";
17+
18+
const TARGET_STYLE: React.CSSProperties = {
19+
display: "grid",
20+
placeItems: "center",
21+
width: 240,
22+
height: 120,
23+
border: "1px dashed var(--ui-description-foreground)",
24+
};
25+
26+
const MenuExample = (): React.JSX.Element => (
27+
<ContextMenu>
28+
<ContextMenuTrigger asChild>
29+
<div style={TARGET_STYLE}>Right-click here</div>
30+
</ContextMenuTrigger>
31+
<ContextMenuContent>
32+
<ContextMenuItem>
33+
<span className="codicon codicon-play" aria-hidden="true" />
34+
Start workspace
35+
</ContextMenuItem>
36+
<ContextMenuItem>
37+
<span className="codicon codicon-debug-restart" aria-hidden="true" />
38+
Restart
39+
</ContextMenuItem>
40+
<ContextMenuItem disabled>
41+
<span className="codicon codicon-stop-circle" aria-hidden="true" />
42+
Stop
43+
</ContextMenuItem>
44+
<ContextMenuSeparator />
45+
<ContextMenuSub>
46+
<ContextMenuSubTrigger>More actions</ContextMenuSubTrigger>
47+
<ContextMenuSubContent>
48+
<ContextMenuItem>Open logs</ContextMenuItem>
49+
<ContextMenuItem>Edit settings</ContextMenuItem>
50+
</ContextMenuSubContent>
51+
</ContextMenuSub>
52+
</ContextMenuContent>
53+
</ContextMenu>
54+
);
55+
56+
const meta: Meta<typeof MenuExample> = {
57+
title: "UI/ContextMenu",
58+
component: MenuExample,
59+
};
60+
61+
export default meta;
62+
type Story = StoryObj<typeof MenuExample>;
63+
64+
/* Right-click at the target's center; without coords the contextmenu
65+
event fires at (0,0) and the menu opens detached from the target. */
66+
async function rightClickCenter(target: Element): Promise<void> {
67+
const rect = target.getBoundingClientRect();
68+
await userEvent.pointer({
69+
keys: "[MouseRight]",
70+
target,
71+
coords: {
72+
clientX: rect.left + rect.width / 2,
73+
clientY: rect.top + rect.height / 2,
74+
},
75+
});
76+
}
77+
78+
export const Closed: Story = {};
79+
80+
/* Chromatic crops to in-flow content, so snapshot stories reserve space
81+
for the portalled menu. */
82+
const OVERLAY_SPACE: React.CSSProperties = { width: 620, height: 400 };
83+
84+
/* Opens the menu and its submenu so Chromatic snapshots the open state. */
85+
export const Open: Story = {
86+
decorators: [
87+
(Story) => (
88+
<div style={OVERLAY_SPACE}>
89+
<Story />
90+
</div>
91+
),
92+
],
93+
parameters: { chromatic: { modes: FOUR_THEME_MODES } },
94+
play: async ({ canvasElement }) => {
95+
const canvas = within(canvasElement);
96+
await rightClickCenter(canvas.getByText("Right-click here"));
97+
const menu = await screen.findByRole("menu");
98+
// Radix moves focus into the menu on open
99+
await waitFor(() =>
100+
expect(menu.contains(document.activeElement)).toBe(true),
101+
);
102+
// Keyboard avoids the submenu hover-open delay
103+
await userEvent.keyboard("{End}{ArrowRight}");
104+
await screen.findByRole("menuitem", { name: "Open logs" });
105+
},
106+
};
107+
108+
export const EscapeClosesMenu: Story = {
109+
parameters: { chromatic: { disableSnapshot: true } },
110+
play: async ({ canvasElement }) => {
111+
const canvas = within(canvasElement);
112+
await rightClickCenter(canvas.getByText("Right-click here"));
113+
await screen.findByRole("menu");
114+
await userEvent.keyboard("{Escape}");
115+
await waitFor(() =>
116+
expect(screen.queryByRole("menu")).not.toBeInTheDocument(),
117+
);
118+
},
119+
};
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
2+
3+
import { cx } from "#cx";
4+
5+
import "../menu.css";
6+
7+
import type { ComponentPropsWithRef } from "react";
8+
9+
export const ContextMenu = ContextMenuPrimitive.Root;
10+
export const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
11+
export const ContextMenuGroup = ContextMenuPrimitive.Group;
12+
export const ContextMenuSub = ContextMenuPrimitive.Sub;
13+
14+
export function ContextMenuContent({
15+
className,
16+
// Native menus wrap focus when arrowing past the last item
17+
loop = true,
18+
...props
19+
}: ComponentPropsWithRef<
20+
typeof ContextMenuPrimitive.Content
21+
>): React.JSX.Element {
22+
return (
23+
<ContextMenuPrimitive.Portal>
24+
<ContextMenuPrimitive.Content
25+
{...props}
26+
loop={loop}
27+
collisionPadding={4}
28+
className={cx("ui-menu", className)}
29+
/>
30+
</ContextMenuPrimitive.Portal>
31+
);
32+
}
33+
34+
export function ContextMenuSubContent({
35+
className,
36+
sideOffset = 2,
37+
loop = true,
38+
...props
39+
}: ComponentPropsWithRef<
40+
typeof ContextMenuPrimitive.SubContent
41+
>): React.JSX.Element {
42+
return (
43+
<ContextMenuPrimitive.Portal>
44+
<ContextMenuPrimitive.SubContent
45+
{...props}
46+
sideOffset={sideOffset}
47+
loop={loop}
48+
collisionPadding={4}
49+
className={cx("ui-menu", className)}
50+
/>
51+
</ContextMenuPrimitive.Portal>
52+
);
53+
}
54+
55+
export function ContextMenuItem({
56+
className,
57+
...props
58+
}: ComponentPropsWithRef<typeof ContextMenuPrimitive.Item>): React.JSX.Element {
59+
return (
60+
<ContextMenuPrimitive.Item
61+
{...props}
62+
className={cx("ui-menu__item", className)}
63+
/>
64+
);
65+
}
66+
67+
export function ContextMenuSubTrigger({
68+
className,
69+
children,
70+
...props
71+
}: ComponentPropsWithRef<
72+
typeof ContextMenuPrimitive.SubTrigger
73+
>): React.JSX.Element {
74+
return (
75+
<ContextMenuPrimitive.SubTrigger
76+
{...props}
77+
className={cx("ui-menu__item", className)}
78+
>
79+
{children}
80+
<span
81+
className="ui-menu__submenu-indicator codicon codicon-chevron-right"
82+
aria-hidden="true"
83+
/>
84+
</ContextMenuPrimitive.SubTrigger>
85+
);
86+
}
87+
88+
export function ContextMenuSeparator({
89+
className,
90+
...props
91+
}: ComponentPropsWithRef<
92+
typeof ContextMenuPrimitive.Separator
93+
>): React.JSX.Element {
94+
return (
95+
<ContextMenuPrimitive.Separator
96+
{...props}
97+
className={cx("ui-menu__separator", className)}
98+
/>
99+
);
100+
}

0 commit comments

Comments
 (0)