diff --git a/.storybook/global.css b/.storybook/global.css index 77cea19e46..c8ce6db507 100644 --- a/.storybook/global.css +++ b/.storybook/global.css @@ -19,8 +19,7 @@ html body { overflow: auto; } -/* Shrink the root from a full-width block to its content so Pixel's - autofit crop stays tight. */ +/* Shrink-wrap the content so Pixel's autofit crop stays tight. */ #storybook-root { width: fit-content; } diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 51d3c6c9f3..2634f46fad 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,13 +69,14 @@ 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 (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. */ + story root to cover them. 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) { @@ -83,19 +85,10 @@ function fitRootToPortals(): void { 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; - } + for (const overlay of document.querySelectorAll( + "[data-radix-popper-content-wrapper]", + )) { + const rect = overlay.getBoundingClientRect(); right = Math.max(right, rect.right - origin.left); bottom = Math.max(bottom, rect.bottom - origin.top); } 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..aace467562 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -19,6 +19,14 @@ 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. + +The radius and spacing tokens mirror VS Code's scale (`baseSizes.ts`), but +only the rungs components actually use are declared; add one when a component +needs it. Native menu, button, and hover paddings are hardcoded rather than +scale-derived, so parity-pinned values stay literals. 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 +43,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, @@ -47,41 +65,13 @@ an interrupted entry animation cannot delay unmounting. High contrast, ## 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. +- 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..a2ea866658 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -19,7 +19,7 @@ "imports": { "#cx": "./src/cx.ts", "#codicons": "./src/codicons.ts", - "#storybook": "./src/storybook.tsx" + "#storybook": "./src/storybook.ts" }, "scripts": { "typecheck": "tsc --noEmit" @@ -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..e6fc80ba21 --- /dev/null +++ b/packages/ui/src/components/Button/Button.css @@ -0,0 +1,30 @@ +/* monaco-text-button, flattened to a flex row: its 4px padding, 16px + leading, and 1px border make the 26px height. */ +.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); +} + +.ui-button:focus { + outline: 1px solid var(--ui-focus-border); + outline-offset: 2px; +} + +.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..b93692cd5c --- /dev/null +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -0,0 +1,23 @@ +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 = {}; 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 @@ -60,9 +76,7 @@ type Story = StoryObj; export const Open: Story = { parameters: { pixel: PIXEL_ALL_THEMES }, play: async ({ canvasElement }) => { - await userEvent.click( - within(canvasElement).getByRole("button", { name: "Workspace actions" }), - ); + await openMenu(canvasElement, "Workspace actions"); await openSubmenuByKeyboard("Open logs"); }, }; @@ -72,9 +86,7 @@ export const ManyItems: Story = { render: () => ( - + {Array.from({ length: 40 }, (_, i) => ( @@ -84,10 +96,7 @@ export const ManyItems: Story = { ), play: async ({ canvasElement }) => { - await userEvent.click( - within(canvasElement).getByRole("button", { name: "Workspace actions" }), - ); - const menu = await screen.findByRole("menu"); + const menu = await openMenu(canvasElement, "Workspace actions"); await waitFor(() => expect(menu.scrollHeight).toBeGreaterThan(menu.clientHeight), ); diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx index b02daab587..92d3c4b093 100644 --- a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx +++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx @@ -2,21 +2,65 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { cx } from "#cx"; -import { Icon } from "../Icon/Icon"; -import "../menu.css"; +import { menuPart } from "../Menu/Menu"; +import "../Menu/Menu.css"; import "../overlay.css"; import type { ComponentPropsWithRef } from "react"; -/** Root state container; wraps the trigger and content. */ +export { MenuKeybinding as DropdownMenuKeybinding } from "../Menu/Menu"; + +/** Root state container. */ export const DropdownMenu = DropdownMenuPrimitive.Root; /** Opens the menu on click; renders its child element via `asChild`. */ export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -/** Scopes one submenu; wraps its sub trigger and sub content. */ +/** Scopes one submenu. */ export const DropdownMenuSub = DropdownMenuPrimitive.Sub; +/** Groups radio items into one exclusive selection. */ +export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; + +/** One selectable action row; a leading `Icon` sits in the gutter. */ +export const DropdownMenuItem = menuPart( + DropdownMenuPrimitive.Item, + "ui-menu__item", +); + +/** Non-interactive heading above a group. */ +export const DropdownMenuLabel = menuPart( + DropdownMenuPrimitive.Label, + "ui-menu__label", +); + +/** Rule between groups of items. */ +export const DropdownMenuSeparator = menuPart( + DropdownMenuPrimitive.Separator, + "ui-menu__separator", +); + +/** A toggleable row; checked shows a gutter check. */ +export const DropdownMenuCheckboxItem = menuPart( + DropdownMenuPrimitive.CheckboxItem, + "ui-menu__item", + { indicator: DropdownMenuPrimitive.ItemIndicator }, +); + +/** One choice in a radio group. */ +export const DropdownMenuRadioItem = menuPart( + DropdownMenuPrimitive.RadioItem, + "ui-menu__item", + { indicator: DropdownMenuPrimitive.ItemIndicator }, +); + +/** The row that opens its submenu. */ +export const DropdownMenuSubTrigger = menuPart( + DropdownMenuPrimitive.SubTrigger, + "ui-menu__item", + { chevron: true }, +); + /** The floating menu surface, portalled to `body`. */ export function DropdownMenuContent({ className, @@ -39,7 +83,7 @@ export function DropdownMenuContent({ ); } -/** The floating submenu surface, opened by `DropdownMenuSubTrigger`. */ +/** The floating submenu surface. */ export function DropdownMenuSubContent({ className, ...props @@ -58,52 +102,3 @@ export function DropdownMenuSubContent({ ); } - -/** One selectable action row; a leading `Icon` sits in the gutter. */ -export function DropdownMenuItem({ - className, - ...props -}: ComponentPropsWithRef< - typeof DropdownMenuPrimitive.Item ->): React.JSX.Element { - return ( - - ); -} - -/** The row that opens its submenu; renders a trailing chevron. */ -export function DropdownMenuSubTrigger({ - className, - children, - ...props -}: ComponentPropsWithRef< - typeof DropdownMenuPrimitive.SubTrigger ->): React.JSX.Element { - return ( - - {children} - - - ); -} - -/** Thin rule between groups of items. */ -export function DropdownMenuSeparator({ - className, - ...props -}: ComponentPropsWithRef< - typeof DropdownMenuPrimitive.Separator ->): React.JSX.Element { - return ( - - ); -} 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..487981abc7 100644 --- a/packages/ui/src/components/IconButton/IconButton.css +++ b/packages/ui/src/components/IconButton/IconButton.css @@ -5,27 +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) { +/* One outline serves hover, toggled, and focus, so :where() keeps the + exclusions weightless and source order alone decides. */ +.ui-icon-button:hover:where(: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:where(:not(:disabled, [aria-pressed="true"])) { background: var(--ui-toolbar-active-background); } -.ui-icon-button:focus-visible { - outline-color: var(--ui-focus-border); - outline-style: solid; -} - +/* A toggled button keeps its active colors on hover, like native */ .ui-icon-button[aria-pressed="true"] { color: var(--ui-input-option-active-foreground); background: var(--ui-input-option-active-background); @@ -33,7 +31,8 @@ outline-style: solid; } -.ui-icon-button:disabled { - opacity: var(--ui-disabled-opacity); - cursor: default; +/* Last: native's ring is an outline, the others border colors, so it wins */ +.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: , }, }; export default meta; type Story = StoryObj; -/* findByRole("tooltip") matches Radix's visually hidden a11y copy, so - open assertions target the visible .ui-tooltip bubble instead. */ -async function openTooltipWithKeyboard(): Promise { +/* Focusing the trigger skips the pointer show delay. */ +async function openTooltipWithKeyboard(): Promise { await userEvent.tab(); await screen.findByRole("tooltip"); - const tooltip = document.querySelector(".ui-tooltip"); - if (!tooltip) { - throw new Error("visible tooltip content not found"); - } - await waitFor(() => expect(tooltip).toBeVisible()); - return tooltip; } export const Open: Story = { parameters: { pixel: PIXEL_ALL_THEMES }, - play: async () => { - await openTooltipWithKeyboard(); - }, + play: openTooltipWithKeyboard, }; -/* Long content wraps at the hover width cap and scrolls once the story - lowers the height cap. */ +/* Shows the wrap at the 700px width cap; maxHeight stands in for the real + half-window height cap, which is too tall to snapshot. */ export const Overflow: Story = { args: { content: Array.from({ length: 8 }, () => LONG_TEXT).join(" "), style: { maxHeight: 160 }, }, - play: async () => { - const tooltip = await openTooltipWithKeyboard(); - await waitFor(() => - expect(tooltip.scrollHeight).toBeGreaterThan(tooltip.clientHeight), - ); - }, + play: openTooltipWithKeyboard, }; diff --git a/packages/ui/src/components/Tooltip/Tooltip.tsx b/packages/ui/src/components/Tooltip/Tooltip.tsx index fc3ca6d061..68a285c53a 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; `Tooltip` throws without one. Sharing a single + * 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..b7d01eeaf5 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 { +/* Shared control geometry: fixed heights and flex centering, never + line-height or vertical padding math, which drift with font metrics. + Focus rings use :focus, not :focus-visible, like VS Code's button:focus. */ +.ui-control { display: inline-flex; align-items: center; justify-content: center; @@ -12,3 +11,8 @@ white-space: nowrap; user-select: none; } + +.ui-control:disabled { + opacity: var(--ui-disabled-opacity); + cursor: default; +} diff --git a/packages/ui/src/components/overlay.css b/packages/ui/src/components/overlay.css index f7e6c5e041..449db7e690 100644 --- a/packages/ui/src/components/overlay.css +++ b/packages/ui/src/components/overlay.css @@ -1,6 +1,5 @@ -/* Shared base for portalled overlay surfaces (menus, tooltips). Surfaces - map their border color and their primitive's Radix available-height - variable onto the --ui-overlay-* properties. */ +/* Shared base for portalled overlays; each surface maps its border color + and its Radix available-height variable onto the --ui-overlay-* names. */ .ui-overlay { box-sizing: border-box; /* Cap to the space Radix reports so oversized content scrolls */ @@ -9,7 +8,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..89b11ffb0a 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 "./keybinding"; +export { + Tooltip, + type TooltipProps, + TooltipProvider, + type TooltipProviderProps, +} from "./components/Tooltip/Tooltip"; export { useVscodeTheme, type VscodeThemeKind } from "./useVscodeTheme"; diff --git a/packages/ui/src/keybinding.ts b/packages/ui/src/keybinding.ts new file mode 100644 index 0000000000..5ec4688f08 --- /dev/null +++ b/packages/ui/src/keybinding.ts @@ -0,0 +1,96 @@ +export type KeybindingPlatform = "mac" | "win" | "linux"; + +/** + * A binding serialization, optionally overridden per platform, using the same + * fields as a `contributes.keybindings` entry. + */ +export type Keybinding = + string | ({ key: string } & Partial>); + +const MODIFIER_ORDER = ["ctrl", "shift", "alt", "meta"] as const; + +type Modifier = (typeof MODIFIER_ORDER)[number]; + +/* 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"; +} + +/* Capitalization covers enter/escape/tab/space/home/end and the F-keys; add + numpad or OEM names to KEY_LABELS if a menu ever needs them. */ +function keyLabel(token: string): string { + return KEY_LABELS[token] ?? token.charAt(0).toUpperCase() + token.slice(1); +} + +function formatChord(chord: string, platform: KeybindingPlatform): string { + const labels = MODIFIER_LABELS[platform]; + const tokens = chord.toLowerCase().split("+").filter(Boolean); + const modifiers = tokens.map((token) => MODIFIER_TOKENS[token]); + const parts = MODIFIER_ORDER.filter((modifier) => + modifiers.includes(modifier), + ).map((modifier) => labels[modifier]); + const key = tokens.findLast((token) => !MODIFIER_TOKENS[token]); + 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). + */ +export function formatKeybinding( + keybinding: Keybinding, + platform: KeybindingPlatform = detectPlatform(), +): string { + const serialized = + typeof keybinding === "string" + ? keybinding + : (keybinding[platform] ?? keybinding.key); + return serialized + .trim() + .split(/\s+/) + .map((chord) => formatChord(chord, platform)) + .join(" "); +} 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.ts similarity index 64% rename from packages/ui/src/storybook.tsx rename to packages/ui/src/storybook.ts index 06957f165b..e799530fea 100644 --- a/packages/ui/src/storybook.tsx +++ b/packages/ui/src/storybook.ts @@ -1,6 +1,4 @@ -import { expect, screen, userEvent, waitFor } from "storybook/test"; - -import "./storybook.css"; +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; /** * Pixel matrix override (`parameters.pixel`) that snapshots a story in every @@ -12,8 +10,16 @@ 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"; +/* Clicks a menu trigger and returns its portalled surface. */ +export async function openMenu( + canvasElement: HTMLElement, + triggerName: string, +): Promise { + await userEvent.click( + within(canvasElement).getByRole("button", { name: triggerName }), + ); + return screen.findByRole("menu"); +} /* Opens the focused menu's submenu; keyboard skips the hover-open delay. */ export async function openSubmenuByKeyboard(itemName: string): Promise { diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 6ab9b09e1c..17907b65ae 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,11 @@ --vscode-sideBar-background, var(--vscode-editor-background) ); + /* For editor-tab and bottom-panel webviews; --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 +70,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); @@ -93,16 +115,23 @@ /* Disabled text */ --ui-disabled-foreground: var(--vscode-disabledForeground); - /* Mirrors the native menu shadow; widget.shadow is null in high - contrast, hiding the shadow like native */ + /* widget.shadow is null in high contrast, hiding the shadow like native */ --ui-widget-shadow: var( --vscode-shadow-lg, 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 */ + --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); names are px times ten */ + --ui-spacing-60: var(--vscode-spacing-size60, 6px); + --ui-spacing-120: var(--vscode-spacing-size120, 12px); + --ui-spacing-160: var(--vscode-spacing-size160, 16px); + --ui-spacing-240: var(--vscode-spacing-size240, 24px); /* Menus */ --ui-menu-background: var(--vscode-menu-background); @@ -130,4 +159,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..342d71990b 100644 --- a/packages/ui/src/useVscodeTheme.ts +++ b/packages/ui/src/useVscodeTheme.ts @@ -5,6 +5,14 @@ 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"; + +const THEME_KINDS: ReadonlyMap = new Map([ + ["vscode-light", "light"], + ["vscode-dark", "dark"], + ["vscode-high-contrast", "high-contrast"], + ["vscode-high-contrast-light", "high-contrast-light"], +]); // One shared observer no matter how many components use the hook. const listeners = new Set<() => void>(); @@ -17,7 +25,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,20 +38,17 @@ function subscribe(onChange: () => void): () => void { }; } -function getSnapshot(): VscodeThemeKind { - switch (document.body.getAttribute(THEME_KIND_ATTRIBUTE)) { - case "vscode-light": - return "light"; - case "vscode-high-contrast": - return "high-contrast"; - case "vscode-high-contrast-light": - return "high-contrast-light"; - default: - return "dark"; - } +/** ` `, so a switch between two themes of one kind also changes it. */ +function getThemeSignature(): string { + const { body } = document; + return `${body.getAttribute(THEME_KIND_ATTRIBUTE)} ${body.getAttribute(THEME_ID_ATTRIBUTE)}`; } -/** The active VS Code theme kind; re-renders when the user switches themes. */ +/** + * The active VS Code theme kind, re-read on every theme switch so token + * values read in JS never go stale. + */ export function useVscodeTheme(): VscodeThemeKind { - return useSyncExternalStore(subscribe, getSnapshot); + const [kind] = useSyncExternalStore(subscribe, getThemeSignature).split(" "); + return THEME_KINDS.get(kind) ?? "dark"; } diff --git a/packages/ui/src/vscode-parity.stories.tsx b/packages/ui/src/vscode-parity.stories.tsx index 5921f8927d..d37c2067f4 100644 --- a/packages/ui/src/vscode-parity.stories.tsx +++ b/packages/ui/src/vscode-parity.stories.tsx @@ -1,21 +1,30 @@ import { VscodeBadge, VscodeButton, + VscodeContextMenu, VscodeIcon, VscodeProgressBar, VscodeProgressRing, VscodeTextfield, VscodeToolbarButton, } from "@vscode-elements/react-elements"; +import { useState } from "react"; -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"; +import { openMenu, PIXEL_ALL_THEMES } from "./storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -45,6 +54,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 +121,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 +225,10 @@ export default meta; type Story = StoryObj; export const SideBySide: Story = {}; + +export const Menu: Story = { + render: () => , + play: async ({ canvasElement }) => { + await openMenu(canvasElement, "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..0092ed395a --- /dev/null +++ b/test/webview/ui/keybinding.test.ts @@ -0,0 +1,50 @@ +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"); + expect(formatKeybinding("shift+cmd+ctrl+alt+r", "mac")).toBe("⌃⇧⌥⌘R"); + }); + + 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("treats cmd, win, and super as the meta modifier", () => { + expect(formatKeybinding("cmd+s", "linux")).toBe("Super+S"); + expect(formatKeybinding("win+s", "linux")).toBe("Super+S"); + expect(formatKeybinding("super+s", "mac")).toBe("⌘S"); + }); + + it("picks the platform's contribution field and falls back to key", () => { + const keys = { key: "ctrl+shift+r", mac: "cmd+alt+r" }; + expect(formatKeybinding(keys, "mac")).toBe("⌥⌘R"); + expect(formatKeybinding(keys, "linux")).toBe("Ctrl+Shift+R"); + expect(formatKeybinding({ key: "ctrl+r" }, "mac")).toBe("⌃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("shift+left", "win")).toBe("Shift+LeftArrow"); + }); + + it("capitalizes keys with no special label", () => { + expect(formatKeybinding("f5", "win")).toBe("F5"); + expect(formatKeybinding("ctrl+enter", "win")).toBe("Ctrl+Enter"); + expect(formatKeybinding("escape", "mac")).toBe("Escape"); + }); + + it("ignores case and repeated modifiers", () => { + expect(formatKeybinding("Ctrl+CTRL+Shift+R", "win")).toBe("Ctrl+Shift+R"); + }); +}); diff --git a/test/webview/ui/useVscodeTheme.test.ts b/test/webview/ui/useVscodeTheme.test.ts index e83bbd4684..3c95f84709 100644 --- a/test/webview/ui/useVscodeTheme.test.ts +++ b/test/webview/ui/useVscodeTheme.test.ts @@ -1,18 +1,28 @@ -import { act, renderHook } from "@testing-library/react"; +import { act, cleanup, renderHook } from "@testing-library/react"; import { afterEach, describe, expect, it } from "vitest"; import { useVscodeTheme } from "@repo/ui"; -function setThemeKind(kind: string | undefined): void { - if (kind === undefined) { - document.body.removeAttribute("data-vscode-theme-kind"); - } else { - document.body.setAttribute("data-vscode-theme-kind", kind); +/** Mirrors the body attributes VS Code sets on the webview; omit to clear. */ +function setTheme(kind?: string, id?: string): void { + const { body } = document; + for (const [attribute, value] of [ + ["data-vscode-theme-kind", kind], + ["data-vscode-theme-id", id], + ] as const) { + if (value === undefined) { + body.removeAttribute(attribute); + } else { + body.setAttribute(attribute, value); + } } } afterEach(() => { - setThemeKind(undefined); + // Unmount before clearing: the attribute change notifies the observer, and + // a still-mounted hook would then update outside act(). + cleanup(); + setTheme(); }); describe("useVscodeTheme", () => { @@ -22,7 +32,7 @@ describe("useVscodeTheme", () => { ["vscode-high-contrast", "high-contrast"], ["vscode-high-contrast-light", "high-contrast-light"], ])("returns %s as %s", (attribute, expected) => { - setThemeKind(attribute); + setTheme(attribute); const { result } = renderHook(() => useVscodeTheme()); @@ -36,16 +46,34 @@ describe("useVscodeTheme", () => { }); it("updates when the theme changes", async () => { - setThemeKind("vscode-dark"); + setTheme("vscode-dark"); const { result } = renderHook(() => useVscodeTheme()); expect(result.current).toBe("dark"); // MutationObserver callbacks are microtasks; flush them inside act. await act(async () => { - setThemeKind("vscode-light"); + setTheme("vscode-light"); await Promise.resolve(); }); expect(result.current).toBe("light"); }); + + it("re-renders when switching between two themes of the same kind", async () => { + setTheme("vscode-dark", "vscode-dark-modern"); + let renders = 0; + + const { result } = renderHook(() => { + renders += 1; + return useVscodeTheme(); + }); + const rendersBefore = renders; + + await act(async () => { + setTheme("vscode-dark", "vscode-dark-plus"); + await Promise.resolve(); + }); + expect(renders).toBeGreaterThan(rendersBefore); + expect(result.current).toBe("dark"); + }); });