From c7371be1de6cf2889c60e38e8e1a8df4ec861788 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 28 Jul 2026 20:33:39 +0300 Subject: [PATCH] fix(ui): close package-wide and overlays gaps from the component library PRs Closes coder/vscode-coder#1046. - Declare Storybook devDependencies; pin storybook via the pnpm catalog - Add Button matching monaco-text-button, with a secondary variant - Add LoadingState reusing the StatePanel skeleton - Tokens: secondary-button colors, small type tracking bodyFontSize-xSmall, overlay z-index, VS Code's cornerRadius and spacing scales, and --ui-panel-background for editor-tab and panel hosts - Re-render useVscodeTheme on same-kind theme switches (one shared observer) - Keep reduced-motion activity cues: striped indeterminate band, tinted ring - Menus: checkbox/radio/label/keybinding wrappers; keybinding hints render the current OS's binding in the native label style (formatKeybinding) - Tooltip: app-level TooltipProvider so trigger-to-trigger hovers skip the delay; tooltips cap at half the window height like native --- .storybook/preview.ts | 6 +- package.json | 2 +- packages/ui/README.md | 53 +++----- packages/ui/package.json | 2 + packages/ui/src/components/Button/Button.css | 35 ++++++ .../src/components/Button/Button.stories.tsx | 31 +++++ packages/ui/src/components/Button/Button.tsx | 32 +++++ .../components/ContextMenu/ContextMenu.tsx | 62 ++++++++++ .../DropdownMenu/DropdownMenu.stories.tsx | 42 +++++-- .../components/DropdownMenu/DropdownMenu.tsx | 62 ++++++++++ .../EmptyState/EmptyState.stories.tsx | 4 +- .../src/components/ErrorState/ErrorState.tsx | 7 +- .../src/components/IconButton/IconButton.css | 10 +- .../src/components/IconButton/IconButton.tsx | 4 +- .../LoadingState/LoadingState.stories.tsx | 28 +++++ .../components/LoadingState/LoadingState.tsx | 34 ++++++ .../components/ProgressBar/ProgressBar.css | 9 ++ .../components/SearchInput/SearchInput.css | 2 +- .../ui/src/components/Spinner/Spinner.css | 11 ++ .../src/components/StatePanel/StatePanel.css | 33 ++--- .../src/components/StatusPill/StatusPill.css | 4 +- .../ui/src/components/Tooltip/Tooltip.css | 6 +- .../components/Tooltip/Tooltip.stories.tsx | 22 ++-- .../ui/src/components/Tooltip/Tooltip.tsx | 51 +++++--- packages/ui/src/components/control.css | 3 +- packages/ui/src/components/keybinding.ts | 114 ++++++++++++++++++ packages/ui/src/components/menu.css | 19 ++- packages/ui/src/components/menu.tsx | 25 ++++ packages/ui/src/components/overlay.css | 2 +- packages/ui/src/index.ts | 27 ++++- packages/ui/src/storybook.css | 20 --- packages/ui/src/storybook.tsx | 5 - packages/ui/src/tokens.css | 55 ++++++++- packages/ui/src/useVscodeTheme.ts | 19 ++- packages/ui/src/vscode-parity.stories.tsx | 91 ++++++++++++-- pnpm-lock.yaml | 13 +- pnpm-workspace.yaml | 1 + test/webview/ui/keybinding.test.ts | 32 +++++ test/webview/ui/useVscodeTheme.test.ts | 29 +++++ 39 files changed, 843 insertions(+), 164 deletions(-) create mode 100644 packages/ui/src/components/Button/Button.css create mode 100644 packages/ui/src/components/Button/Button.stories.tsx create mode 100644 packages/ui/src/components/Button/Button.tsx create mode 100644 packages/ui/src/components/LoadingState/LoadingState.stories.tsx create mode 100644 packages/ui/src/components/LoadingState/LoadingState.tsx create mode 100644 packages/ui/src/components/keybinding.ts create mode 100644 packages/ui/src/components/menu.tsx delete mode 100644 packages/ui/src/storybook.css create mode 100644 test/webview/ui/keybinding.test.ts diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 51d3c6c9f3..43e632d96c 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -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; @@ -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 diff --git a/package.json b/package.json index a2b0b42332..b1387e742d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ui/README.md b/packages/ui/README.md index bc548386f4..ddf231ac8b 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -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 @@ -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, @@ -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 diff --git a/packages/ui/package.json b/packages/ui/package.json index 8f64bb0e86..940281fe90 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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:" } } diff --git a/packages/ui/src/components/Button/Button.css b/packages/ui/src/components/Button/Button.css new file mode 100644 index 0000000000..bf0282a042 --- /dev/null +++ b/packages/ui/src/components/Button/Button.css @@ -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); +} diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx new file mode 100644 index 0000000000..a578b5952a --- /dev/null +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -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 => ( +
+ + + +
+); + +const meta: Meta = { + title: "UI/Button", + component: ButtonStates, + parameters: { pixel: PIXEL_ALL_THEMES }, +}; +export default meta; +type Story = StoryObj; + +export const States: Story = { + play: async ({ canvasElement }) => { + await expect( + within(canvasElement).getByRole("button", { name: "Rebuild" }), + ).toBeDisabled(); + }, +}; diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx new file mode 100644 index 0000000000..a6a735c929 --- /dev/null +++ b/packages/ui/src/components/Button/Button.tsx @@ -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 ( + + @@ -37,6 +37,22 @@ const MenuExample = (): React.JSX.Element => ( Stop + + Rebuild + + + + + Start on connect + + + Sort by + + Name + Status + More actions @@ -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"); }, }; @@ -72,9 +94,7 @@ export const ManyItems: Story = { render: () => ( - + {Array.from({ length: 40 }, (_, i) => ( diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx index b02daab587..0f6e1b8101 100644 --- a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx +++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx @@ -8,6 +8,8 @@ import "../overlay.css"; import type { ComponentPropsWithRef } from "react"; +export { MenuKeybinding as DropdownMenuKeybinding } from "../menu"; + /** Root state container; wraps the trigger and content. */ export const DropdownMenu = DropdownMenuPrimitive.Root; @@ -74,6 +76,66 @@ export function DropdownMenuItem({ ); } +/** A toggleable row; shows the native-style check in the gutter when checked. */ +export function DropdownMenuCheckboxItem({ + className, + children, + ...props +}: ComponentPropsWithRef< + typeof DropdownMenuPrimitive.CheckboxItem +>): React.JSX.Element { + return ( + + + + + {children} + + ); +} + +/** Groups `DropdownMenuRadioItem`s into one exclusive selection. */ +export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +/** One choice in a radio group; native menus check the active choice. */ +export function DropdownMenuRadioItem({ + className, + children, + ...props +}: ComponentPropsWithRef< + typeof DropdownMenuPrimitive.RadioItem +>): React.JSX.Element { + return ( + + + + + {children} + + ); +} + +/** Non-interactive heading above a group of items. */ +export function DropdownMenuLabel({ + className, + ...props +}: ComponentPropsWithRef< + typeof DropdownMenuPrimitive.Label +>): React.JSX.Element { + return ( + + ); +} + /** The row that opens its submenu; renders a trailing chevron. */ export function DropdownMenuSubTrigger({ className, diff --git a/packages/ui/src/components/EmptyState/EmptyState.stories.tsx b/packages/ui/src/components/EmptyState/EmptyState.stories.tsx index d2115447c1..5ba265869a 100644 --- a/packages/ui/src/components/EmptyState/EmptyState.stories.tsx +++ b/packages/ui/src/components/EmptyState/EmptyState.stories.tsx @@ -1,5 +1,7 @@ import { PIXEL_ALL_THEMES } from "#storybook"; +import { Button } from "../Button/Button"; + import { EmptyState } from "./EmptyState"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -9,7 +11,7 @@ const EmptyStates = (): React.JSX.Element => ( Create workspace} + action={} /> - {retryLabel} - - ) : undefined) + (onRetry ? : undefined) } /> ); diff --git a/packages/ui/src/components/IconButton/IconButton.css b/packages/ui/src/components/IconButton/IconButton.css index 721abb4635..0efa9dfc02 100644 --- a/packages/ui/src/components/IconButton/IconButton.css +++ b/packages/ui/src/components/IconButton/IconButton.css @@ -5,23 +5,25 @@ color: var(--ui-toolbar-foreground); background: transparent; border: 0; - border-radius: 5px; + border-radius: var(--ui-radius-medium); cursor: pointer; outline: 1px solid transparent; outline-offset: -1px; } -.ui-icon-button:hover:not(:disabled) { +/* A toggled button keeps its active colors on hover, like native */ +.ui-icon-button:hover:not(:disabled, [aria-pressed="true"]) { background: var(--ui-toolbar-hover-background); outline-color: var(--ui-toolbar-hover-outline); outline-style: dashed; } -.ui-icon-button:active:not(:disabled) { +.ui-icon-button:active:not(:disabled, [aria-pressed="true"]) { background: var(--ui-toolbar-active-background); } -.ui-icon-button:focus-visible { +/* Native buttons outline on :focus, so a click shows it too */ +.ui-icon-button:focus { outline-color: var(--ui-focus-border); outline-style: solid; } diff --git a/packages/ui/src/components/IconButton/IconButton.tsx b/packages/ui/src/components/IconButton/IconButton.tsx index e700e1c166..e5d308661a 100644 --- a/packages/ui/src/components/IconButton/IconButton.tsx +++ b/packages/ui/src/components/IconButton/IconButton.tsx @@ -17,11 +17,12 @@ export interface IconButtonProps extends Omit< label: string; } +/* No default title: native toolbar buttons hint with the styled hover + widget, not the browser box. Wrap in Tooltip for that. */ export function IconButton({ icon, label, className, - title = label, type = "button", ...props }: IconButtonProps): React.JSX.Element { @@ -29,7 +30,6 @@ export function IconButton({ - ), + children: , }, }; diff --git a/packages/ui/src/components/Tooltip/Tooltip.tsx b/packages/ui/src/components/Tooltip/Tooltip.tsx index fc3ca6d061..b6a757ac2b 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -6,7 +6,22 @@ import "../overlay.css"; import "./Tooltip.css"; -import type { ComponentPropsWithRef, ReactNode } from "react"; +import type { ComponentProps, ComponentPropsWithRef, ReactNode } from "react"; + +export type TooltipProviderProps = ComponentProps< + typeof TooltipPrimitive.Provider +>; + +/** + * App-level tooltip context; mount once near the root. Sharing one provider + * lets a pointer moving between nearby triggers skip the show delay, like + * native hovers. The default delay is VS Code's `workbench.hover.delay`. + */ +export function TooltipProvider( + props: TooltipProviderProps, +): React.JSX.Element { + return ; +} export interface TooltipProps extends Omit< ComponentPropsWithRef, @@ -15,33 +30,29 @@ export interface TooltipProps extends Omit< content: ReactNode; /** The trigger element; must accept a forwarded ref (asChild). */ children: ReactNode; - /** Show delay in ms. Defaults to VS Code's workbench.hover.delay. */ - delayDuration?: number; } +/** Hover bubble matching the native hover widget; requires a `TooltipProvider` ancestor. */ export function Tooltip({ content, children, className, - delayDuration = 500, ...props }: TooltipProps): React.JSX.Element { return ( - - - {children} - - - {content} - - - - + + {children} + + + {content} + + + ); } diff --git a/packages/ui/src/components/control.css b/packages/ui/src/components/control.css index ca092c32bb..6bdb935cec 100644 --- a/packages/ui/src/components/control.css +++ b/packages/ui/src/components/control.css @@ -1,8 +1,7 @@ /* Shared control geometry. Controls center content with fixed heights and flex alignment, never with line-height or vertical padding math, so text stays optically centered regardless of font metrics. */ -.ui-control, -.ui-state-panel__action button { +.ui-control { display: inline-flex; align-items: center; justify-content: center; diff --git a/packages/ui/src/components/keybinding.ts b/packages/ui/src/components/keybinding.ts new file mode 100644 index 0000000000..e7bf4c0bb5 --- /dev/null +++ b/packages/ui/src/components/keybinding.ts @@ -0,0 +1,114 @@ +export type KeybindingPlatform = "mac" | "win" | "linux"; + +/** + * A binding serialization, or one per platform using the same fields as a + * package.json keybindings contribution (`key` is the fallback). + */ +export type Keybinding = + string | { key?: string; mac?: string; win?: string; linux?: string }; + +type Modifier = "ctrl" | "shift" | "alt" | "meta"; + +const MODIFIER_ORDER: readonly Modifier[] = ["ctrl", "shift", "alt", "meta"]; + +/* Modifier tokens as VS Code serializes them, aliases included */ +const MODIFIER_TOKENS: Readonly> = { + ctrl: "ctrl", + shift: "shift", + alt: "alt", + meta: "meta", + cmd: "meta", + win: "meta", + super: "meta", +}; + +const MODIFIER_LABELS: Readonly< + Record>> +> = { + mac: { ctrl: "⌃", shift: "⇧", alt: "⌥", meta: "⌘" }, + win: { ctrl: "Ctrl", shift: "Shift", alt: "Alt", meta: "Windows" }, + linux: { ctrl: "Ctrl", shift: "Shift", alt: "Alt", meta: "Super" }, +}; + +/* Serialization tokens whose native label differs from capitalization */ +const KEY_LABELS: Readonly> = { + up: "UpArrow", + down: "DownArrow", + left: "LeftArrow", + right: "RightArrow", + pageup: "PageUp", + pagedown: "PageDown", + delete: "Del", +}; + +function detectPlatform(): KeybindingPlatform { + // Keybindings follow the client machine, which is what the webview's + // user agent reports even in remote sessions. + const agent = navigator.userAgent; + if (agent.includes("Mac")) { + return "mac"; + } + if (agent.includes("Windows")) { + return "win"; + } + return "linux"; +} + +function keyLabel(token: string): string { + const label = KEY_LABELS[token]; + if (label) { + return label; + } + if (token.length === 1 || /^f\d+$/.test(token)) { + return token.toUpperCase(); + } + // ponytail: capitalization covers enter/escape/tab/space/home/end; + // extend KEY_LABELS if a menu ever needs numpad or OEM key names + return token.charAt(0).toUpperCase() + token.slice(1); +} + +function formatChord(chord: string, platform: KeybindingPlatform): string { + const labels = MODIFIER_LABELS[platform]; + const modifiers = new Set(); + let key = ""; + for (const token of chord.toLowerCase().split("+")) { + const modifier = MODIFIER_TOKENS[token]; + if (modifier) { + modifiers.add(modifier); + } else if (token) { + key = token; + } + } + const parts = MODIFIER_ORDER.filter((modifier) => + modifiers.has(modifier), + ).map((modifier) => labels[modifier]); + if (key) { + parts.push(keyLabel(key)); + } + return parts.join(platform === "mac" ? "" : "+"); +} + +/** + * Renders a keybinding serialization ("ctrl+shift+r", chords separated by + * spaces) the way native menus label it on the current (or given) platform: + * `⇧⌘R` on macOS, `Ctrl+Shift+R` elsewhere (VS Code's UILabelProvider and + * key code uiMap). VS Code gives extensions no way to look up the effective + * binding for a command, so callers pass their contributed defaults. + */ +export function formatKeybinding( + keybinding: Keybinding, + platform: KeybindingPlatform = detectPlatform(), +): string { + const serialized = + typeof keybinding === "string" + ? keybinding + : (keybinding[platform] ?? keybinding.key); + if (!serialized) { + return ""; + } + return serialized + .trim() + .split(/\s+/) + .map((chord) => formatChord(chord, platform)) + .join(" "); +} diff --git a/packages/ui/src/components/menu.css b/packages/ui/src/components/menu.css index 935590dfc1..3673deb7c5 100644 --- a/packages/ui/src/components/menu.css +++ b/packages/ui/src/components/menu.css @@ -46,7 +46,8 @@ color: var(--ui-disabled-foreground); } -/* Leading icons sit in the left gutter so labels stay aligned */ +/* Leading icons sit in the left gutter so labels stay aligned; a checked + item's check renders as one, like the native menu-item-check. */ .ui-menu__item > .ui-icon:not(.ui-menu__submenu-indicator) { position: absolute; left: 0; @@ -54,6 +55,22 @@ text-align: center; } +/* Non-interactive heading above a group of items */ +.ui-menu__label { + display: flex; + align-items: center; + height: 24px; + padding: 0 var(--ui-menu-gutter); + color: var(--ui-description-foreground); +} + +/* Right-aligned keybinding hint, dimmed like the native .keybinding */ +.ui-menu__keybinding { + margin-inline-start: auto; + padding-inline-start: 1em; + opacity: 0.7; +} + /* Chevron in the right gutter, like the native indicator */ .ui-menu__submenu-indicator { position: absolute; diff --git a/packages/ui/src/components/menu.tsx b/packages/ui/src/components/menu.tsx new file mode 100644 index 0000000000..e8963ee0f4 --- /dev/null +++ b/packages/ui/src/components/menu.tsx @@ -0,0 +1,25 @@ +import { cx } from "#cx"; + +import { formatKeybinding, type Keybinding } from "./keybinding"; +import "./menu.css"; + +import type { ComponentPropsWithRef } from "react"; + +/** + * Right-aligned keybinding hint inside a menu item, dimmed like native. + * `keys` takes the fields of a keybindings contribution and renders the + * current OS's binding in its native label style. + */ +export function MenuKeybinding({ + keys, + className, + ...props +}: Omit, "children"> & { + keys: Keybinding; +}): React.JSX.Element { + return ( + + {formatKeybinding(keys)} + + ); +} diff --git a/packages/ui/src/components/overlay.css b/packages/ui/src/components/overlay.css index f7e6c5e041..6889f41fd3 100644 --- a/packages/ui/src/components/overlay.css +++ b/packages/ui/src/components/overlay.css @@ -9,7 +9,7 @@ border: 1px solid var(--ui-overlay-border, transparent); border-radius: var(--ui-radius-large); box-shadow: var(--ui-widget-shadow); - z-index: 40; + z-index: var(--ui-z-index-overlay); } @media (forced-colors: active) { diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index a62e0e3b93..6b10979871 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,3 +1,4 @@ +export { Button, type ButtonProps } from "./components/Button/Button"; export { EmptyState, type EmptyStateProps, @@ -11,6 +12,10 @@ export { IconButton, type IconButtonProps, } from "./components/IconButton/IconButton"; +export { + LoadingState, + type LoadingStateProps, +} from "./components/LoadingState/LoadingState"; export { ProgressBar, type ProgressBarProps, @@ -28,8 +33,13 @@ export { export type { CodiconName } from "./codicons"; export { ContextMenu, + ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItem, + ContextMenuKeybinding, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRadioItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, @@ -38,13 +48,28 @@ export { } from "./components/ContextMenu/ContextMenu"; export { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, + DropdownMenuKeybinding, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger, } from "./components/DropdownMenu/DropdownMenu"; -export { Tooltip, type TooltipProps } from "./components/Tooltip/Tooltip"; +export { + formatKeybinding, + type Keybinding, + type KeybindingPlatform, +} from "./components/keybinding"; +export { + Tooltip, + type TooltipProps, + TooltipProvider, + type TooltipProviderProps, +} from "./components/Tooltip/Tooltip"; export { useVscodeTheme, type VscodeThemeKind } from "./useVscodeTheme"; diff --git a/packages/ui/src/storybook.css b/packages/ui/src/storybook.css deleted file mode 100644 index 181ac87273..0000000000 --- a/packages/ui/src/storybook.css +++ /dev/null @@ -1,20 +0,0 @@ -/* Story stand-in: a VS Code secondary button like consuming webviews - render; secondary button colors have no --ui-* tokens yet. */ -.story-trigger { - padding: 0.4em 1em; - border: 1px solid var(--ui-button-border); - border-radius: 2px; - background: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); - font: inherit; - cursor: pointer; -} - -.story-trigger:hover { - background: var(--vscode-button-secondaryHoverBackground); -} - -.story-trigger:focus-visible { - outline: 1px solid var(--ui-focus-border); - outline-offset: 2px; -} diff --git a/packages/ui/src/storybook.tsx b/packages/ui/src/storybook.tsx index 06957f165b..bc251be8fb 100644 --- a/packages/ui/src/storybook.tsx +++ b/packages/ui/src/storybook.tsx @@ -1,7 +1,5 @@ import { expect, screen, userEvent, waitFor } from "storybook/test"; -import "./storybook.css"; - /** * Pixel matrix override (`parameters.pixel`) that snapshots a story in every * captured VS Code theme; the base matrix in pixel.jsonc is light/dark only. @@ -12,9 +10,6 @@ export const PIXEL_ALL_THEMES = { }, } as const; -/* Story stand-in for a webview-styled button; styled in storybook.css. */ -export const STORY_TRIGGER_CLASS = "story-trigger"; - /* Opens the focused menu's submenu; keyboard skips the hover-open delay. */ export async function openSubmenuByKeyboard(itemName: string): Promise { const menu = await screen.findByRole("menu"); diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 6ab9b09e1c..a45efc8918 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -1,13 +1,14 @@ /* * Semantic tokens mapped to the `--vscode-*` variables VS Code injects - * into webviews — the single place the library is theme-matched to - * VS Code. + * into webviews; the single place the library is theme-matched. */ :root { /* Typography */ --ui-font-family: var(--vscode-font-family); --ui-font-size: var(--vscode-font-size); --ui-font-weight: var(--vscode-font-weight); + /* Badges and pills; the native badge size */ + --ui-font-size-small: var(--vscode-bodyFontSize-xSmall, 11px); /* Surfaces and text */ --ui-foreground: var(--vscode-foreground); @@ -15,6 +16,12 @@ --vscode-sideBar-background, var(--vscode-editor-background) ); + /* For webviews hosted in an editor tab or the bottom panel; + --ui-background assumes the sidebar */ + --ui-panel-background: var( + --vscode-panel-background, + var(--vscode-editor-background) + ); --ui-description-foreground: var(--vscode-descriptionForeground); --ui-border: var(--vscode-sideBar-border, transparent); --ui-link-foreground: var(--vscode-textLink-foreground); @@ -64,6 +71,22 @@ --vscode-button-hoverBackground, var(--ui-button-background) ); + --ui-button-secondary-foreground: var( + --vscode-button-secondaryForeground, + var(--ui-foreground) + ); + --ui-button-secondary-background: var( + --vscode-button-secondaryBackground, + transparent + ); + --ui-button-secondary-hover-background: var( + --vscode-button-secondaryHoverBackground, + var(--ui-toolbar-hover-background) + ); + --ui-button-secondary-border: var( + --vscode-button-secondaryBorder, + var(--ui-button-border) + ); /* Activity and feedback */ --ui-progress-foreground: var(--vscode-progressBar-background); @@ -100,9 +123,28 @@ 0 0 12px var(--vscode-widget-shadow, transparent) ); - /* Corner radii; fallbacks predate the host's corner radius tokens */ - --ui-radius-large: var(--vscode-cornerRadius-large, 5px); - --ui-radius-medium: var(--vscode-cornerRadius-medium, 4px); + /* Corner radii, VS Code's scale (baseSizes.ts); fallbacks predate + the host's corner radius tokens */ + --ui-radius-small: var(--vscode-cornerRadius-small, 4px); + --ui-radius-medium: var(--vscode-cornerRadius-medium, 6px); + --ui-radius-large: var(--vscode-cornerRadius-large, 8px); + --ui-radius-circle: var(--vscode-cornerRadius-circle, 9999px); + + /* Spacing, VS Code's scale (baseSizes.ts) mirrored in full so new + uses never edit this file; names are px times ten */ + --ui-spacing-20: var(--vscode-spacing-size20, 2px); + --ui-spacing-40: var(--vscode-spacing-size40, 4px); + --ui-spacing-60: var(--vscode-spacing-size60, 6px); + --ui-spacing-80: var(--vscode-spacing-size80, 8px); + --ui-spacing-100: var(--vscode-spacing-size100, 10px); + --ui-spacing-120: var(--vscode-spacing-size120, 12px); + --ui-spacing-160: var(--vscode-spacing-size160, 16px); + --ui-spacing-200: var(--vscode-spacing-size200, 20px); + --ui-spacing-240: var(--vscode-spacing-size240, 24px); + --ui-spacing-280: var(--vscode-spacing-size280, 28px); + --ui-spacing-320: var(--vscode-spacing-size320, 32px); + --ui-spacing-360: var(--vscode-spacing-size360, 36px); + --ui-spacing-400: var(--vscode-spacing-size400, 40px); /* Menus */ --ui-menu-background: var(--vscode-menu-background); @@ -130,4 +172,7 @@ /* Only defined by high contrast themes; transparent elsewhere */ --ui-contrast-border: var(--vscode-contrastBorder, transparent); + + /* Portalled overlays; matches the native hover widget's stacking */ + --ui-z-index-overlay: 40; } diff --git a/packages/ui/src/useVscodeTheme.ts b/packages/ui/src/useVscodeTheme.ts index ab3de253d6..c953c7e378 100644 --- a/packages/ui/src/useVscodeTheme.ts +++ b/packages/ui/src/useVscodeTheme.ts @@ -5,6 +5,7 @@ export type VscodeThemeKind = "light" | "dark" | "high-contrast" | "high-contrast-light"; const THEME_KIND_ATTRIBUTE = "data-vscode-theme-kind"; +const THEME_ID_ATTRIBUTE = "data-vscode-theme-id"; // One shared observer no matter how many components use the hook. const listeners = new Set<() => void>(); @@ -17,7 +18,7 @@ function subscribe(onChange: () => void): () => void { }); observer.observe(document.body, { attributes: true, - attributeFilter: [THEME_KIND_ATTRIBUTE], + attributeFilter: [THEME_KIND_ATTRIBUTE, THEME_ID_ATTRIBUTE], }); } listeners.add(onChange); @@ -30,7 +31,7 @@ function subscribe(onChange: () => void): () => void { }; } -function getSnapshot(): VscodeThemeKind { +function getThemeKind(): VscodeThemeKind { switch (document.body.getAttribute(THEME_KIND_ATTRIBUTE)) { case "vscode-light": return "light"; @@ -43,7 +44,17 @@ function getSnapshot(): VscodeThemeKind { } } -/** The active VS Code theme kind; re-renders when the user switches themes. */ +/* The kind alone misses switches between two themes of the same kind */ +function getSnapshot(): string { + return `${document.body.getAttribute(THEME_ID_ATTRIBUTE)}\n${document.body.getAttribute(THEME_KIND_ATTRIBUTE)}`; +} + +/** + * The active VS Code theme kind. Re-renders on any theme switch, including + * between two themes of the same kind, so token values read in JS never + * go stale. + */ export function useVscodeTheme(): VscodeThemeKind { - return useSyncExternalStore(subscribe, getSnapshot); + useSyncExternalStore(subscribe, getSnapshot); + return getThemeKind(); } diff --git a/packages/ui/src/vscode-parity.stories.tsx b/packages/ui/src/vscode-parity.stories.tsx index 5921f8927d..9f81e8ab31 100644 --- a/packages/ui/src/vscode-parity.stories.tsx +++ b/packages/ui/src/vscode-parity.stories.tsx @@ -1,19 +1,29 @@ import { VscodeBadge, VscodeButton, + VscodeContextMenu, VscodeIcon, VscodeProgressBar, VscodeProgressRing, VscodeTextfield, VscodeToolbarButton, } from "@vscode-elements/react-elements"; +import { useState } from "react"; +import { screen, userEvent, within } from "storybook/test"; -import "./components/control.css"; +import { Button } from "./components/Button/Button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuKeybinding, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./components/DropdownMenu/DropdownMenu"; import { IconButton } from "./components/IconButton/IconButton"; import { ProgressBar } from "./components/ProgressBar/ProgressBar"; import { SearchInput } from "./components/SearchInput/SearchInput"; import { Spinner } from "./components/Spinner/Spinner"; -import "./components/StatePanel/StatePanel.css"; import { StatusPill } from "./components/StatusPill/StatusPill"; import { PIXEL_ALL_THEMES } from "./storybook"; @@ -45,6 +55,19 @@ const Row = ({ ); +/* Stateful like the reference's own toggleable/checked handling */ +const PinToggle = (): React.JSX.Element => { + const [pressed, setPressed] = useState(true); + return ( + setPressed(!pressed)} + /> + ); +}; + const Parity = (): React.JSX.Element => (
( ours={ <> - + } reference={ @@ -99,11 +122,17 @@ const Parity = (): React.JSX.Element => ( // Narrower than the reference by design: VS Code core's // monaco-text-button uses 4px/8px padding; vscode-elements uses 13px. ours={ -
- -
+ <> + + + + } + reference={ + <> + Try again + Cancel + } - reference={Try again} /> (
); +/* The reference menu renders inline; ours is a real portalled DropdownMenu, + so the play function opens it under its trigger. */ +const MenuParity = (): React.JSX.Element => ( +
+ + + + + + Start workspace + Open logs + + + Rebuild + + + + + +
+); + const meta: Meta = { title: "UI/VSCodeParity", component: Parity, @@ -159,3 +226,13 @@ export default meta; type Story = StoryObj; export const SideBySide: Story = {}; + +export const Menu: Story = { + render: () => , + play: async ({ canvasElement }) => { + await userEvent.click( + within(canvasElement).getByRole("button", { name: "Menu" }), + ); + await screen.findByRole("menu"); + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 543215dd05..138bc469e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,9 @@ catalogs: react-dom: specifier: ^19.2.8 version: 19.2.8 + storybook: + specifier: ^10.5.5 + version: 10.5.5 typescript: specifier: ^6.0.3 version: 6.0.3 @@ -293,7 +296,7 @@ importers: specifier: 'catalog:' version: 19.2.8(react@19.2.8) storybook: - specifier: ^10.5.5 + specifier: 'catalog:' version: 10.5.5(@types/react@19.2.17)(bufferutil@4.1.0)(prettier@3.9.6)(react@19.2.8)(utf-8-validate@6.0.6) typescript: specifier: 'catalog:' @@ -451,6 +454,9 @@ importers: specifier: 'catalog:' version: 0.0.46-24 devDependencies: + '@storybook/react-vite': + specifier: 'catalog:' + version: 10.5.5(@types/react-dom@19.2.3)(@types/react@19.2.17)(esbuild@0.28.1)(react-dom@19.2.8)(react@19.2.8)(storybook@10.5.5)(supports-color@10.2.2)(typescript@6.0.3)(vite@8.1.5) '@types/react': specifier: 'catalog:' version: 19.2.17 @@ -463,6 +469,9 @@ importers: react-dom: specifier: 'catalog:' version: 19.2.8(react@19.2.8) + storybook: + specifier: 'catalog:' + version: 10.5.5(@types/react@19.2.17)(bufferutil@4.1.0)(prettier@3.9.6)(react@19.2.8)(utf-8-validate@6.0.6) typescript: specifier: 'catalog:' version: 6.0.3 @@ -703,7 +712,7 @@ packages: hasBin: true '@coder/coder@https://codeload.github.com/coder/coder/tar.gz/2e8d80abf74d655b54ace1533b6c13102ea8d549': - resolution: {gitHosted: true, tarball: https://codeload.github.com/coder/coder/tar.gz/2e8d80abf74d655b54ace1533b6c13102ea8d549} + resolution: {gitHosted: true, integrity: sha512-By+7tehWIsLhq3RGifYR3nvrfvc70KFpuGwurBZW+U175uQhZIqTluIcXV4bSIyW3bW9XlR1L6dlH2oH68xOXA==, tarball: https://codeload.github.com/coder/coder/tar.gz/2e8d80abf74d655b54ace1533b6c13102ea8d549} version: 0.0.0 '@coder/pixel-storybook@0.2.2': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index df06481594..0d14da8c10 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,6 +18,7 @@ catalog: date-fns: ^4.4.0 react: ^19.2.8 react-dom: ^19.2.8 + storybook: ^10.5.5 typescript: ^6.0.3 vite: ^8.1.5 diff --git a/test/webview/ui/keybinding.test.ts b/test/webview/ui/keybinding.test.ts new file mode 100644 index 0000000000..662a52d32a --- /dev/null +++ b/test/webview/ui/keybinding.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; + +import { formatKeybinding } from "@repo/ui"; + +describe("formatKeybinding", () => { + it("renders macOS glyphs in canonical order without separators", () => { + expect(formatKeybinding("shift+cmd+r", "mac")).toBe("⇧⌘R"); + expect(formatKeybinding("ctrl+alt+up", "mac")).toBe("⌃⌥UpArrow"); + }); + + it("joins full labels with + on Windows and Linux", () => { + expect(formatKeybinding("ctrl+shift+r", "win")).toBe("Ctrl+Shift+R"); + expect(formatKeybinding("meta+e", "win")).toBe("Windows+E"); + expect(formatKeybinding("meta+e", "linux")).toBe("Super+E"); + }); + + it("picks the platform's contribution field and falls back to key", () => { + const keys = { key: "ctrl+shift+r", mac: "cmd+shift+r" }; + expect(formatKeybinding(keys, "mac")).toBe("⇧⌘R"); + expect(formatKeybinding(keys, "linux")).toBe("Ctrl+Shift+R"); + }); + + it("formats each chord of a sequence", () => { + expect(formatKeybinding("ctrl+k ctrl+s", "win")).toBe("Ctrl+K Ctrl+S"); + }); + + it("maps named keys to their native labels", () => { + expect(formatKeybinding("alt+pagedown", "linux")).toBe("Alt+PageDown"); + expect(formatKeybinding("cmd+delete", "mac")).toBe("⌘Del"); + expect(formatKeybinding("f5", "win")).toBe("F5"); + }); +}); diff --git a/test/webview/ui/useVscodeTheme.test.ts b/test/webview/ui/useVscodeTheme.test.ts index e83bbd4684..2a8e7b2aad 100644 --- a/test/webview/ui/useVscodeTheme.test.ts +++ b/test/webview/ui/useVscodeTheme.test.ts @@ -11,8 +11,17 @@ function setThemeKind(kind: string | undefined): void { } } +function setThemeId(id: string | undefined): void { + if (id === undefined) { + document.body.removeAttribute("data-vscode-theme-id"); + } else { + document.body.setAttribute("data-vscode-theme-id", id); + } +} + afterEach(() => { setThemeKind(undefined); + setThemeId(undefined); }); describe("useVscodeTheme", () => { @@ -48,4 +57,24 @@ describe("useVscodeTheme", () => { }); expect(result.current).toBe("light"); }); + + it("re-renders when switching between two themes of the same kind", async () => { + setThemeKind("vscode-dark"); + setThemeId("vscode-dark-modern"); + let renders = 0; + + const { result } = renderHook(() => { + renders += 1; + return useVscodeTheme(); + }); + const rendersBefore = renders; + + // MutationObserver callbacks are microtasks; flush them inside act. + await act(async () => { + setThemeId("vscode-dark-plus"); + await Promise.resolve(); + }); + expect(renders).toBeGreaterThan(rendersBefore); + expect(result.current).toBe("dark"); + }); });