Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions .storybook/global.css
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,13 @@ html {
background: var(--vscode-sideBar-background);
}

/* Typography lives on body like real webviews, so portalled content
(menus, tooltips) inherits it too. */
body {
color: var(--vscode-editor-foreground);
font-family: var(--vscode-font-family);
font-weight: var(--vscode-font-weight);
font-size: var(--vscode-font-size);
margin: 0;
padding: 0;
overflow: hidden;
Expand All @@ -15,10 +21,6 @@ body {
#root {
overscroll-behavior-x: none;
background-color: var(--vscode-sideBar-background);
color: var(--vscode-editor-foreground);
font-family: var(--vscode-font-family);
font-weight: var(--vscode-font-weight);
font-size: var(--vscode-font-size);
margin: 0;
max-width: 100%;
/* arbitrary size choice for the rough VSCode sidebar size */
Expand Down
19 changes: 19 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,25 @@ export default defineConfig(
},
},

// Keep the UI package independent from other workspace packages.
{
files: ["packages/ui/**/*.{ts,tsx}"],
rules: {
"import-x/no-relative-packages": "error",
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["@repo/*"],
message: "packages/ui must not import other workspace packages.",
},
],
},
],
},
},

// React rules with type-checked analysis (covers hooks, JSX, DOM)
{
files: ["packages/**/*.{ts,tsx}"],
Expand Down
27 changes: 25 additions & 2 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,37 @@ import "@repo/ui/tokens.css";

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

## Overlays

`Tooltip`, `ContextMenu`, and `DropdownMenu` wrap the Radix primitives and
are styled to match the native VS Code menu and hover widgets. The menus
expose Radix's compound parts as flat named exports
(`DropdownMenuTrigger`, `DropdownMenuItem`, …); `Tooltip` is a single
component taking a `content` prop, with a 500ms show delay matching VS
Code's `workbench.hover.delay` default. Each `Tooltip` mounts its own
Radix provider, so the cross-trigger skip-delay window is not shared
between tooltips; if that matters, expose a shared provider. Checkbox/
radio items, labels, and keybinding hints are not wrapped yet.

Overlay content is portalled to `body` and inherits webview typography
from there; surface colors come from the `--ui-menu-*` and
`--ui-tooltip-*` tokens. High contrast follows the VS Code contrast
variables, and the styles handle `forced-colors` and
`prefers-reduced-motion`.

## Rules

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

- No `workspace:*` runtime dependencies (no `@repo/shared`,
`@repo/webview-shared`).
- `react` stays a peer dependency.
`@repo/webview-shared`); ESLint rejects `@repo/*` imports and relative
cross-package imports in this package.
- `react` stays a peer dependency; the Radix overlay primitives are the
only other runtime dependencies.
- New entry points go in the `exports` map; consumers never deep-import
`src/`.
- Shared internals are reached through `package.json` subpath imports
(`#cx`, `#storybook`). These resolve only inside this package and ship
with it, so they survive a standalone NPM split.
- New components define their VS Code mappings in `tokens.css`, not
inline `--vscode-*` references.
11 changes: 11 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@
"description": "Shared component library for VS Code webviews",
"private": true,
"type": "module",
"sideEffects": [
"**/*.css",
"./storybook.preview.ts"
],
"exports": {
".": {
"types": "./src/index.ts",
Expand All @@ -12,10 +16,17 @@
"./codicon.css": "./src/codicon.css",
"./tokens.css": "./src/tokens.css"
},
"imports": {
"#cx": "./src/cx.ts",
"#storybook": "./src/storybook.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-context-menu": "catalog:",
"@radix-ui/react-dropdown-menu": "catalog:",
"@radix-ui/react-tooltip": "catalog:",
"@vscode/codicons": "catalog:"
},
"peerDependencies": {
Expand Down
119 changes: 119 additions & 0 deletions packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { expect, screen, userEvent, waitFor, within } from "storybook/test";

import { FOUR_THEME_MODES } from "#storybook";

import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "./ContextMenu";

import type { Meta, StoryObj } from "@storybook/react-vite";

const TARGET_STYLE: React.CSSProperties = {
display: "grid",
placeItems: "center",
width: 240,
height: 120,
border: "1px dashed var(--ui-description-foreground)",
};

const MenuExample = (): React.JSX.Element => (
<ContextMenu>
<ContextMenuTrigger asChild>
<div style={TARGET_STYLE}>Right-click here</div>
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
<span className="codicon codicon-play" aria-hidden="true" />
Start workspace
</ContextMenuItem>
<ContextMenuItem>
<span className="codicon codicon-debug-restart" aria-hidden="true" />
Restart
</ContextMenuItem>
<ContextMenuItem disabled>
<span className="codicon codicon-stop-circle" aria-hidden="true" />
Stop
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>More actions</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Open logs</ContextMenuItem>
<ContextMenuItem>Edit settings</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
);

const meta: Meta<typeof MenuExample> = {
title: "UI/ContextMenu",
component: MenuExample,
};

export default meta;
type Story = StoryObj<typeof MenuExample>;

/* Right-click at the target's center; without coords the contextmenu
event fires at (0,0) and the menu opens detached from the target. */
async function rightClickCenter(target: Element): Promise<void> {
const rect = target.getBoundingClientRect();
await userEvent.pointer({
keys: "[MouseRight]",
target,
coords: {
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
},
});
}

export const Closed: Story = {};

/* Chromatic crops to in-flow content, so snapshot stories reserve space
for the portalled menu. */
const OVERLAY_SPACE: React.CSSProperties = { width: 620, height: 400 };

/* Opens the menu and its submenu so Chromatic snapshots the open state. */
export const Open: Story = {
decorators: [
(Story) => (
<div style={OVERLAY_SPACE}>
<Story />
</div>
),
],
parameters: { chromatic: { modes: FOUR_THEME_MODES } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await rightClickCenter(canvas.getByText("Right-click here"));
const menu = await screen.findByRole("menu");
// Radix moves focus into the menu on open
await waitFor(() =>
expect(menu.contains(document.activeElement)).toBe(true),
);
// Keyboard avoids the submenu hover-open delay
await userEvent.keyboard("{End}{ArrowRight}");
await screen.findByRole("menuitem", { name: "Open logs" });
},
};

export const EscapeClosesMenu: Story = {
parameters: { chromatic: { disableSnapshot: true } },
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await rightClickCenter(canvas.getByText("Right-click here"));
await screen.findByRole("menu");
await userEvent.keyboard("{Escape}");
await waitFor(() =>
expect(screen.queryByRole("menu")).not.toBeInTheDocument(),
);
},
};
100 changes: 100 additions & 0 deletions packages/ui/src/components/ContextMenu/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";

import { cx } from "#cx";

import "../menu.css";

import type { ComponentPropsWithRef } from "react";

export const ContextMenu = ContextMenuPrimitive.Root;
export const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
export const ContextMenuGroup = ContextMenuPrimitive.Group;
export const ContextMenuSub = ContextMenuPrimitive.Sub;

export function ContextMenuContent({
className,
// Native menus wrap focus when arrowing past the last item
loop = true,
...props
}: ComponentPropsWithRef<
typeof ContextMenuPrimitive.Content
>): React.JSX.Element {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
{...props}
loop={loop}
collisionPadding={4}
className={cx("ui-menu", className)}
/>
</ContextMenuPrimitive.Portal>
);
}

export function ContextMenuSubContent({
className,
sideOffset = 2,
loop = true,
...props
}: ComponentPropsWithRef<
typeof ContextMenuPrimitive.SubContent
>): React.JSX.Element {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.SubContent
{...props}
sideOffset={sideOffset}
loop={loop}
collisionPadding={4}
className={cx("ui-menu", className)}
/>
</ContextMenuPrimitive.Portal>
);
}

export function ContextMenuItem({
className,
...props
}: ComponentPropsWithRef<typeof ContextMenuPrimitive.Item>): React.JSX.Element {
return (
<ContextMenuPrimitive.Item
{...props}
className={cx("ui-menu__item", className)}
/>
);
}

export function ContextMenuSubTrigger({
className,
children,
...props
}: ComponentPropsWithRef<
typeof ContextMenuPrimitive.SubTrigger
>): React.JSX.Element {
return (
<ContextMenuPrimitive.SubTrigger
{...props}
className={cx("ui-menu__item", className)}
>
{children}
<span
className="ui-menu__submenu-indicator codicon codicon-chevron-right"
aria-hidden="true"
/>
</ContextMenuPrimitive.SubTrigger>
);
}

export function ContextMenuSeparator({
className,
...props
}: ComponentPropsWithRef<
typeof ContextMenuPrimitive.Separator
>): React.JSX.Element {
return (
<ContextMenuPrimitive.Separator
{...props}
className={cx("ui-menu__separator", className)}
/>
);
}
Loading