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
6 changes: 4 additions & 2 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,9 @@ if (

/**
* Applies a captured VS Code theme dump (`pnpm sync:vscode-themes`) as one
* `:root` stylesheet and mirrors VS Code's body attribute for theme-aware
* hooks. Synchronous and idempotent, so stories render fully themed.
* `:root` stylesheet and mirrors VS Code's body theme attributes for
* theme-aware hooks. Synchronous and idempotent, so stories render fully
* themed.
*/
let appliedTheme: string | undefined;

Expand All @@ -68,6 +69,7 @@ function applyTheme(requested: string): void {
.map(([property, value]) => `${property}: ${value};`)
.join("")}}`;
document.body.setAttribute("data-vscode-theme-kind", `vscode-${slug}`);
document.body.setAttribute("data-vscode-theme-id", slug);
}

/* Pixel's autofit crop follows in-flow layout, but portalled overlays
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -843,7 +843,7 @@
"prettier": "^3.9.6",
"react": "catalog:",
"react-dom": "catalog:",
"storybook": "^10.5.5",
"storybook": "catalog:",
"typescript": "catalog:",
"typescript-eslint": "^8.65.0",
"utf-8-validate": "^6.0.6",
Expand Down
53 changes: 20 additions & 33 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import "@repo/ui/codicon.css";

`tokens.css` is the only layer that references VS Code's injected
`--vscode-*` variables. Components reference `--ui-*` tokens only.
`--ui-background` is the sidebar surface, the common webview host; a webview
hosted in an editor tab or bottom panel uses `--ui-panel-background` instead,
since VS Code gives webviews no host signal to resolve it automatically.

Component CSS is inherit-first: typography and text color come from the
webview (`font: inherit`), and controls center content with a fixed height
Expand All @@ -35,9 +38,19 @@ after the library overrides any default (width, height, spacing).
`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.
`DropdownMenuItem`, `DropdownMenuCheckboxItem`, …): checkbox and radio
items show a check in the icon gutter, `*Label` renders a group heading, and
`*Keybinding` renders a shortcut hint. Pass `keys` the same `key`/`mac`/
`win`/`linux` fields as a keybindings contribution to get the current OS's
binding in its native label style (`⇧⌘R` on macOS, `Ctrl+Shift+R`
elsewhere); `formatKeybinding` does the same for other surfaces, such as
tooltips.

`Tooltip` is a single component taking a `content` prop, and requires a
`TooltipProvider` ancestor. Mount one provider per app so that a pointer
moving between nearby triggers skips the show delay, like native hovers.
The delay defaults to 500ms, matching VS Code's `workbench.hover.delay`,
and tooltips stop growing at half the window height.

Overlay content is portalled to `body`, inherits webview typography from
there, and shares the `.ui-overlay` base for stacking, border, shadow,
Expand All @@ -49,39 +62,13 @@ an interrupted entry animation cannot delay unmounting. High contrast,

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.
- Keybinding hints show the contributed defaults the consumer passes, not
user remaps: VS Code exposes no API for extensions to resolve a command's
effective keybinding.
- List/selection-row tokens are deferred to the Tree suite (#1037).

## Codicons

Expand Down
2 changes: 2 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@
"react-dom": "catalog:"
},
"devDependencies": {
"@storybook/react-vite": "catalog:",
"@types/react": "catalog:",
"@vscode-elements/react-elements": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"storybook": "catalog:",
"typescript": "catalog:"
}
}
35 changes: 35 additions & 0 deletions packages/ui/src/components/Button/Button.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/* Matches monaco-text-button: 26px tall with 8px horizontal padding. */
.ui-button {
height: 26px;
padding: 0 8px;
color: var(--ui-button-foreground);
background: var(--ui-button-background);
border: 1px solid var(--ui-button-border);
border-radius: var(--ui-radius-small);
cursor: pointer;
}

.ui-button:hover:not(:disabled) {
background: var(--ui-button-hover-background);
}

/* Native buttons outline on :focus, so a click shows it too */
.ui-button:focus {
outline: 1px solid var(--ui-focus-border);
outline-offset: 2px;
}

.ui-button:disabled {
opacity: var(--ui-disabled-opacity);
cursor: default;
}

.ui-button--secondary {
color: var(--ui-button-secondary-foreground);
background: var(--ui-button-secondary-background);
border-color: var(--ui-button-secondary-border);
}

.ui-button--secondary:hover:not(:disabled) {
background: var(--ui-button-secondary-hover-background);
}
31 changes: 31 additions & 0 deletions packages/ui/src/components/Button/Button.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { expect, within } from "storybook/test";

import { PIXEL_ALL_THEMES } from "#storybook";

import { Button } from "./Button";

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

const ButtonStates = (): React.JSX.Element => (
<div style={{ display: "flex", gap: "8px", alignItems: "center" }}>
<Button>Start workspace</Button>
<Button variant="secondary">Open logs</Button>
<Button disabled>Rebuild</Button>
</div>
);

const meta: Meta<typeof ButtonStates> = {
title: "UI/Button",
component: ButtonStates,
parameters: { pixel: PIXEL_ALL_THEMES },
};
export default meta;
type Story = StoryObj<typeof ButtonStates>;

export const States: Story = {
play: async ({ canvasElement }) => {
await expect(
within(canvasElement).getByRole("button", { name: "Rebuild" }),
).toBeDisabled();
},
};
32 changes: 32 additions & 0 deletions packages/ui/src/components/Button/Button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { type ComponentProps } from "react";

import { cx } from "#cx";

import "../control.css";

import "./Button.css";

export interface ButtonProps extends ComponentProps<"button"> {
variant?: "primary" | "secondary";
}

/** Text button matching VS Code's monaco-text-button. */
export function Button({
variant = "primary",
className,
type = "button",
...props
}: ButtonProps): React.JSX.Element {
return (
<button
{...props}
type={type}
className={cx(
"ui-control",
"ui-button",
variant === "secondary" && "ui-button--secondary",
className,
)}
/>
);
}
62 changes: 62 additions & 0 deletions packages/ui/src/components/ContextMenu/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import "../overlay.css";

import type { ComponentPropsWithRef } from "react";

export { MenuKeybinding as ContextMenuKeybinding } from "../menu";

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

Expand Down Expand Up @@ -70,6 +72,66 @@ export function ContextMenuItem({
);
}

/** A toggleable row; shows the native-style check in the gutter when checked. */
export function ContextMenuCheckboxItem({
className,
children,
...props
}: ComponentPropsWithRef<
typeof ContextMenuPrimitive.CheckboxItem
>): React.JSX.Element {
return (
<ContextMenuPrimitive.CheckboxItem
{...props}
className={cx("ui-menu__item", className)}
>
<ContextMenuPrimitive.ItemIndicator asChild>
<Icon name="check" />
</ContextMenuPrimitive.ItemIndicator>
{children}
</ContextMenuPrimitive.CheckboxItem>
);
}

/** Groups `ContextMenuRadioItem`s into one exclusive selection. */
export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;

/** One choice in a radio group; native menus check the active choice. */
export function ContextMenuRadioItem({
className,
children,
...props
}: ComponentPropsWithRef<
typeof ContextMenuPrimitive.RadioItem
>): React.JSX.Element {
return (
<ContextMenuPrimitive.RadioItem
{...props}
className={cx("ui-menu__item", className)}
>
<ContextMenuPrimitive.ItemIndicator asChild>
<Icon name="check" />
</ContextMenuPrimitive.ItemIndicator>
{children}
</ContextMenuPrimitive.RadioItem>
);
}

/** Non-interactive heading above a group of items. */
export function ContextMenuLabel({
className,
...props
}: ComponentPropsWithRef<
typeof ContextMenuPrimitive.Label
>): React.JSX.Element {
return (
<ContextMenuPrimitive.Label
{...props}
className={cx("ui-menu__label", className)}
/>
);
}

/** The row that opens its submenu; renders a trailing chevron. */
export function ContextMenuSubTrigger({
className,
Expand Down
42 changes: 31 additions & 11 deletions packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { expect, screen, userEvent, waitFor, within } from "storybook/test";

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

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

import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuKeybinding,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
Expand All @@ -24,9 +26,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
const MenuExample = (): React.JSX.Element => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={STORY_TRIGGER_CLASS}>
Workspace actions
</button>
<Button variant="secondary">Workspace actions</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
Expand All @@ -37,6 +37,22 @@ const MenuExample = (): React.JSX.Element => (
<Icon name="stop-circle" />
Stop
</DropdownMenuItem>
<DropdownMenuItem>
Rebuild
<DropdownMenuKeybinding
keys={{ key: "ctrl+shift+r", mac: "cmd+shift+r" }}
/>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem checked>
Start on connect
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
<DropdownMenuRadioGroup value="name">
<DropdownMenuRadioItem value="name">Name</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="status">Status</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuSub>
<DropdownMenuSubTrigger>More actions</DropdownMenuSubTrigger>
Expand All @@ -63,6 +79,12 @@ export const Open: Story = {
await userEvent.click(
within(canvasElement).getByRole("button", { name: "Workspace actions" }),
);
await expect(
await screen.findByRole("menuitemcheckbox", { name: "Start on connect" }),
).toHaveAttribute("aria-checked", "true");
await expect(
screen.getByRole("menuitemradio", { name: "Name" }),
).toHaveAttribute("aria-checked", "true");
await openSubmenuByKeyboard("Open logs");
},
};
Expand All @@ -72,9 +94,7 @@ export const ManyItems: Story = {
render: () => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" className={STORY_TRIGGER_CLASS}>
Workspace actions
</button>
<Button variant="secondary">Workspace actions</Button>
</DropdownMenuTrigger>
<DropdownMenuContent style={{ maxHeight: 240 }}>
{Array.from({ length: 40 }, (_, i) => (
Expand Down
Loading