Skip to content
Merged
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
9 changes: 8 additions & 1 deletion .storybook/global.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* Storybook stand-ins for what the VS Code host provides outside the
webview document: the sidebar surface color and viewport sizing.
Typography and text color come from themes/generated/default-styles.css. */
Typography and text color come from themes/generated/default-styles.css,
applied on body so portalled content (menus, tooltips) inherits it. */

html,
#storybook-preview-wrapper {
Expand All @@ -18,6 +19,12 @@ html body {
overflow: auto;
}

/* Shrink the root from a full-width block to its content so Pixel's
autofit crop stays tight. */
#storybook-root {
width: fit-content;
}

#root {
max-width: 100%;
/* arbitrary size choice for the rough VSCode sidebar size; stories
Expand Down
46 changes: 45 additions & 1 deletion .storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,54 @@ function applyTheme(requested: string): void {
document.body.setAttribute("data-vscode-theme-kind", `vscode-${slug}`);
}

/* Pixel's autofit crop follows in-flow layout, but portalled overlays
(menus, tooltips) are out of flow and would be cropped away. Grow the
story root to cover any element portalled to body. Relies on the
padded (top-left anchored) layout: growth only extends right and
down, so already-positioned overlays never move. */
function fitRootToPortals(): void {
const root = document.getElementById("root");
if (!root) {
return;
}
const origin = root.getBoundingClientRect();
let right = 0;
let bottom = 0;
for (const el of document.body.children) {
// Skip Storybook chrome (root, loaders, error display, a11y helpers)
if (
!(el instanceof HTMLElement) ||
el.id.startsWith("storybook-") ||
el.classList.contains("sb-wrapper")
) {
continue;
}
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) {
continue;
}
right = Math.max(right, rect.right - origin.left);
bottom = Math.max(bottom, rect.bottom - origin.top);
}
if (right > 0 && bottom > 0) {
// Slack keeps shadows and focus outlines in frame
root.style.minWidth = `${Math.ceil(right) + 16}px`;
root.style.minHeight = `${Math.ceil(bottom) + 16}px`;
}
}

const preview: Preview = {
parameters: {
layout: "centered",
// Top-left anchored; fitRootToPortals depends on this
layout: "padded",
},
beforeEach: () => {
// Undo fitRootToPortals sizing when dev story switches reuse the root
const root = document.getElementById("root");
root?.style.removeProperty("min-width");
root?.style.removeProperty("min-height");
},
afterEach: fitRootToPortals,
globalTypes: {
theme: {
description: "Global theme for components",
Expand Down
65 changes: 60 additions & 5 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,67 @@ import "@repo/ui/codicon.css";

Component CSS is inherit-first: typography and text color come from the
webview (`font: inherit`), and controls center content with a fixed height
plus the shared `.ui-control` flex base in `components/control.css` — never
with line-height or vertical padding math, which drifts off-center with
font metrics.
plus the shared `.ui-control` flex base in `components/control.css`.
Line-height and vertical padding math drift off-center with font metrics,
so components avoid them.

Every component forwards `className` and `style` to its root element, and
default rules use single-class specificity, so a consumer class imported
after the library overrides any default (width, height, spacing).

## Overlays

`Tooltip`, `ContextMenu`, and `DropdownMenu` wrap the Radix primitives,
styled to match the native VS Code menu and hover widgets. 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.

Overlay content is portalled to `body`, inherits webview typography from
there, and shares the `.ui-overlay` base for stacking, border, shadow,
and scrolling. Menus fade in like native menus, gated on `data-state` so
an interrupted entry animation cannot delay unmounting. High contrast,
`forced-colors`, and `prefers-reduced-motion` are handled.

## Known gaps

Deliberate deferrals, fine to fix later.

Overlays:

- Menus only support plain action items; Radix's checkbox/radio items,
group labels, and keybinding hints have no styled wrappers yet.
- Moving the pointer from one tooltip trigger straight to another replays
the full 500ms delay, where native shows the next hover instantly. The
fix is one shared `TooltipProvider` per app instead of one per
`Tooltip`.
- Overlay shadows are darker than native in dark themes: menus in VS Code
use `shadow-lg`, which webviews cannot read, so the closest available
`widget.shadow` stands in.
- A very tall tooltip fills most of the viewport before it scrolls, where
native hovers stop at half the window height.

Package-wide:

- There is no `Button`; the VS Code button style exists only inside the
state panels, and secondary-button colors have no `--ui-*` tokens.
- Only the Empty and Error panels ship; a Loading panel would need the
shared panel skeleton, which stays internal.
- The token layer maps what shipped components need: there are no
list/selection-row, spacing, typography, or z-index tokens, and the
`--ui-radius-*` tokens are only adopted by the overlays, with older
controls hardcoding their radii.
- `--ui-background` assumes a sidebar webview; a webview hosted in an
editor tab or panel renders on the sidebar color.
- `useVscodeTheme` reports the theme kind only; switching between two
themes of the same kind does not notify subscribers.
- Under `prefers-reduced-motion` the indeterminate `ProgressBar` renders
as a full bar and the `Spinner` as a static ring, with no other
activity cue.
- Story helpers compile against root-hoisted Storybook packages; a
standalone split needs its own Storybook devDependencies.

## Codicons

`CodiconName` is derived directly from the installed
Expand All @@ -39,8 +92,10 @@ without a generated source file or a runtime list in the public API.
## Isolation

ESLint rejects `@repo/*` imports and relative cross-package imports in
`packages/ui` TypeScript and TSX source. `react` remains a peer dependency, and
public consumers import from the package root or its declared CSS exports.
`packages/ui` TypeScript and TSX source. `react` remains a peer dependency;
the only runtime dependencies are the Radix overlay primitives and
`@vscode/codicons`. Public consumers import from the package root or its
declared CSS exports.

Shared internals are reached through `package.json` subpath imports (`#cx`,
`#codicons`, `#storybook`). These resolve only inside this package and ship
Expand Down
13 changes: 11 additions & 2 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 @@ -15,21 +19,26 @@
"imports": {
"#cx": "./src/cx.ts",
"#codicons": "./src/codicons.ts",
"#storybook": "./src/storybook.ts"
"#storybook": "./src/storybook.tsx"
},
"scripts": {
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-context-menu": "^2.3.7",
"@radix-ui/react-dropdown-menu": "^2.1.24",
"@radix-ui/react-tooltip": "^1.2.16",
"@vscode/codicons": "catalog:"
},
"peerDependencies": {
"react": "catalog:"
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@types/react": "catalog:",
"@vscode-elements/react-elements": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"typescript": "catalog:"
}
}
79 changes: 79 additions & 0 deletions packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { userEvent, within } from "storybook/test";

import { openSubmenuByKeyboard, PIXEL_ALL_THEMES } from "#storybook";

import { Icon } from "../Icon/Icon";

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>
<Icon name="play" />
Start workspace
</ContextMenuItem>
<ContextMenuItem disabled>
<Icon name="stop-circle" />
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>;

export const Open: Story = {
parameters: { pixel: PIXEL_ALL_THEMES },
play: async ({ canvasElement }) => {
const target = within(canvasElement).getByText("Right-click here");
/* Right-click at the target's center; without coords the menu
opens at (0,0), detached from the target. */
const rect = target.getBoundingClientRect();
await userEvent.pointer({
keys: "[MouseRight]",
target,
coords: {
clientX: rect.left + rect.width / 2,
clientY: rect.top + rect.height / 2,
},
});
await openSubmenuByKeyboard("Open logs");
},
};
105 changes: 105 additions & 0 deletions packages/ui/src/components/ContextMenu/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";

import { cx } from "#cx";

import { Icon } from "../Icon/Icon";
import "../menu.css";
import "../overlay.css";

import type { ComponentPropsWithRef } from "react";

/** Root state container; wraps the trigger and content. */
export const ContextMenu = ContextMenuPrimitive.Root;

/** The right-click target area; renders its child element via `asChild`. */
export const ContextMenuTrigger = ContextMenuPrimitive.Trigger;

/** Scopes one submenu; wraps its sub trigger and sub content. */
export const ContextMenuSub = ContextMenuPrimitive.Sub;

/** The floating menu surface, portalled to `body` at the pointer. */
export function ContextMenuContent({
className,
...props
}: ComponentPropsWithRef<
typeof ContextMenuPrimitive.Content
>): React.JSX.Element {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
// Native menus wrap focus when arrowing past the last item
loop
collisionPadding={4}
{...props}
className={cx("ui-overlay ui-menu", className)}
/>
</ContextMenuPrimitive.Portal>
);
}

/** The floating submenu surface, opened by `ContextMenuSubTrigger`. */
export function ContextMenuSubContent({
className,
...props
}: ComponentPropsWithRef<
typeof ContextMenuPrimitive.SubContent
>): React.JSX.Element {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.SubContent
sideOffset={2}
loop
collisionPadding={4}
{...props}
className={cx("ui-overlay ui-menu", className)}
/>
</ContextMenuPrimitive.Portal>
);
}

/** One selectable action row; a leading `Icon` sits in the gutter. */
export function ContextMenuItem({
className,
...props
}: ComponentPropsWithRef<typeof ContextMenuPrimitive.Item>): React.JSX.Element {
return (
<ContextMenuPrimitive.Item
{...props}
className={cx("ui-menu__item", className)}
/>
);
}

/** The row that opens its submenu; renders a trailing chevron. */
export function ContextMenuSubTrigger({
className,
children,
...props
}: ComponentPropsWithRef<
typeof ContextMenuPrimitive.SubTrigger
>): React.JSX.Element {
return (
<ContextMenuPrimitive.SubTrigger
{...props}
className={cx("ui-menu__item", className)}
>
{children}
<Icon name="chevron-right" className="ui-menu__submenu-indicator" />
</ContextMenuPrimitive.SubTrigger>
);
}

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