From c7371be1de6cf2889c60e38e8e1a8df4ec861788 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 28 Jul 2026 20:33:39 +0300 Subject: [PATCH 1/2] 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"); + }); }); From 93c06c17db25a77c1302ea14674dad6e6c665788 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 29 Jul 2026 12:10:06 +0300 Subject: [PATCH 2/2] refactor(ui): simplify the overlays, share the menu parts, fix the focus ring Build the styled menu parts from a `menuPart` factory instead of two near-identical files. The Radix packages create separate menu scopes, so components cannot be shared, but their part props are the same `Menu*Props` types, so the factory keeps the public types exact: wrong-part and unknown props are still rejected, and the React Compiler still memoizes the component it produces. `Content` and `SubContent` stay explicit, their defaults differ. Following StatePanel's precedent the shared module becomes `components/Menu/Menu.tsx` with its stylesheet beside it, leaving `components/` holding only the `control.css` and `overlay.css` bases; `keybinding.ts` is a formatter rather than a component, so it sits with the other package-level modules. Require `key` in the object form of `Keybinding`, matching VS Code's `contributes.keybindings` schema where the platform fields are overrides. The all-undefined case disappears, so `formatKeybinding` no longer needs an empty-string escape hatch. Drop a provably dead branch in `keyLabel` and the `Set` plus mutable accumulator in `formatChord`, and derive the theme kind from the store snapshot rather than subscribing for the re-render and reading the DOM again. Order the toolbar button's outline rules so focus wins over the hover and toggled rings, with `:where()` keeping the exclusions weightless. VS Code outlines `button:focus` through a dedicated `outline` while a toggle's active state is an inline `borderColor`, so the ring natively stays visible on a pressed or hovered button; ours hid it. This is the only rendering change. Re-checked the overlay, menu, and button metrics against VS Code: the radius tokens and the literal hover values are faithful, so no other declaration moved, and the reduced-motion and forced-colors branches are untouched. Cut comments that restated their code, keeping one line of JSDoc per public component, and drop the nine spacing tokens that had no call sites, with the token policy now stated in the README. Story assertions that restated the props a story had just passed are gone, the menu-opening steps are shared through `#storybook`, and the context menu and loading panel now render their remaining visual cases. Unmount before the theme test clears its attributes: vitest runs afterEach in reverse registration order, so the reset ran before Testing Library's cleanup and the observer could update a mounted hook outside act(). --- .storybook/global.css | 3 +- .storybook/preview.ts | 23 +-- packages/ui/README.md | 7 +- packages/ui/package.json | 2 +- packages/ui/src/components/Button/Button.css | 9 +- .../src/components/Button/Button.stories.tsx | 10 +- .../ContextMenu/ContextMenu.stories.tsx | 21 +++ .../components/ContextMenu/ContextMenu.tsx | 161 ++++++----------- .../DropdownMenu/DropdownMenu.stories.tsx | 19 +- .../components/DropdownMenu/DropdownMenu.tsx | 163 ++++++------------ .../src/components/IconButton/IconButton.css | 21 +-- .../LoadingState/LoadingState.stories.tsx | 12 +- .../components/LoadingState/LoadingState.tsx | 4 +- .../components/{menu.css => Menu/Menu.css} | 36 ++-- packages/ui/src/components/Menu/Menu.tsx | 71 ++++++++ .../components/ProgressBar/ProgressBar.css | 2 +- .../ui/src/components/Spinner/Spinner.css | 4 +- .../src/components/StatePanel/StatePanel.css | 4 +- .../ui/src/components/Tooltip/Tooltip.css | 5 +- .../components/Tooltip/Tooltip.stories.tsx | 28 +-- .../ui/src/components/Tooltip/Tooltip.tsx | 6 +- packages/ui/src/components/control.css | 11 +- packages/ui/src/components/menu.tsx | 25 --- packages/ui/src/components/overlay.css | 5 +- packages/ui/src/index.ts | 2 +- .../ui/src/{components => }/keybinding.ts | 44 ++--- .../ui/src/{storybook.tsx => storybook.ts} | 13 +- packages/ui/src/tokens.css | 21 +-- packages/ui/src/useVscodeTheme.ts | 36 ++-- packages/ui/src/vscode-parity.stories.tsx | 8 +- test/webview/ui/keybinding.test.ts | 22 ++- test/webview/ui/useVscodeTheme.test.ts | 45 +++-- 32 files changed, 359 insertions(+), 484 deletions(-) rename packages/ui/src/components/{menu.css => Menu/Menu.css} (70%) create mode 100644 packages/ui/src/components/Menu/Menu.tsx delete mode 100644 packages/ui/src/components/menu.tsx rename packages/ui/src/{components => }/keybinding.ts (65%) rename packages/ui/src/{storybook.tsx => storybook.ts} (64%) 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 43e632d96c..2634f46fad 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -74,9 +74,9 @@ function applyTheme(requested: string): void { /* 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) { @@ -85,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/packages/ui/README.md b/packages/ui/README.md index ddf231ac8b..aace467562 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -23,6 +23,11 @@ import "@repo/ui/codicon.css"; 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 plus the shared `.ui-control` flex base in `components/control.css`. @@ -60,8 +65,6 @@ an interrupted entry animation cannot delay unmounting. High contrast, ## Known gaps -Deliberate deferrals, fine to fix later. - - 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. diff --git a/packages/ui/package.json b/packages/ui/package.json index 940281fe90..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" diff --git a/packages/ui/src/components/Button/Button.css b/packages/ui/src/components/Button/Button.css index bf0282a042..e6fc80ba21 100644 --- a/packages/ui/src/components/Button/Button.css +++ b/packages/ui/src/components/Button/Button.css @@ -1,4 +1,5 @@ -/* Matches monaco-text-button: 26px tall with 8px horizontal padding. */ +/* 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; @@ -13,17 +14,11 @@ 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); diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx index a578b5952a..b93692cd5c 100644 --- a/packages/ui/src/components/Button/Button.stories.tsx +++ b/packages/ui/src/components/Button/Button.stories.tsx @@ -1,5 +1,3 @@ -import { expect, within } from "storybook/test"; - import { PIXEL_ALL_THEMES } from "#storybook"; import { Button } from "./Button"; @@ -22,10 +20,4 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const States: Story = { - play: async ({ canvasElement }) => { - await expect( - within(canvasElement).getByRole("button", { name: "Rebuild" }), - ).toBeDisabled(); - }, -}; +export const States: Story = {}; diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx index aa7814c504..c1eac7498a 100644 --- a/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx +++ b/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx @@ -6,8 +6,13 @@ import { Icon } from "../Icon/Icon"; import { ContextMenu, + ContextMenuCheckboxItem, ContextMenuContent, ContextMenuItem, + ContextMenuKeybinding, + ContextMenuLabel, + ContextMenuRadioGroup, + ContextMenuRadioItem, ContextMenuSeparator, ContextMenuSub, ContextMenuSubContent, @@ -39,6 +44,22 @@ const MenuExample = (): React.JSX.Element => ( Stop + + Rebuild + + + + + Start on connect + + + Sort by + + Name + Status + More actions diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.tsx index bb02fffc80..951d149f68 100644 --- a/packages/ui/src/components/ContextMenu/ContextMenu.tsx +++ b/packages/ui/src/components/ContextMenu/ContextMenu.tsx @@ -2,23 +2,65 @@ import * as ContextMenuPrimitive from "@radix-ui/react-context-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"; -export { MenuKeybinding as ContextMenuKeybinding } from "../menu"; +export { MenuKeybinding as ContextMenuKeybinding } from "../Menu/Menu"; -/** Root state container; wraps the trigger and content. */ +/** Root state container. */ export const ContextMenu = ContextMenuPrimitive.Root; /** The right-click target area; renders its child element via `asChild`. */ export const ContextMenuTrigger = ContextMenuPrimitive.Trigger; -/** Scopes one submenu; wraps its sub trigger and sub content. */ +/** Scopes one submenu. */ export const ContextMenuSub = ContextMenuPrimitive.Sub; +/** Groups radio items into one exclusive selection. */ +export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; + +/** One selectable action row; a leading `Icon` sits in the gutter. */ +export const ContextMenuItem = menuPart( + ContextMenuPrimitive.Item, + "ui-menu__item", +); + +/** Non-interactive heading above a group. */ +export const ContextMenuLabel = menuPart( + ContextMenuPrimitive.Label, + "ui-menu__label", +); + +/** Rule between groups of items. */ +export const ContextMenuSeparator = menuPart( + ContextMenuPrimitive.Separator, + "ui-menu__separator", +); + +/** A toggleable row; checked shows a gutter check. */ +export const ContextMenuCheckboxItem = menuPart( + ContextMenuPrimitive.CheckboxItem, + "ui-menu__item", + { indicator: ContextMenuPrimitive.ItemIndicator }, +); + +/** One choice in a radio group. */ +export const ContextMenuRadioItem = menuPart( + ContextMenuPrimitive.RadioItem, + "ui-menu__item", + { indicator: ContextMenuPrimitive.ItemIndicator }, +); + +/** The row that opens its submenu. */ +export const ContextMenuSubTrigger = menuPart( + ContextMenuPrimitive.SubTrigger, + "ui-menu__item", + { chevron: true }, +); + /** The floating menu surface, portalled to `body` at the pointer. */ export function ContextMenuContent({ className, @@ -39,7 +81,7 @@ export function ContextMenuContent({ ); } -/** The floating submenu surface, opened by `ContextMenuSubTrigger`. */ +/** The floating submenu surface. */ export function ContextMenuSubContent({ className, ...props @@ -58,110 +100,3 @@ export function ContextMenuSubContent({ ); } - -/** One selectable action row; a leading `Icon` sits in the gutter. */ -export function ContextMenuItem({ - className, - ...props -}: ComponentPropsWithRef): React.JSX.Element { - return ( - - ); -} - -/** 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 ( - - - - - {children} - - ); -} - -/** 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 ( - - - - - {children} - - ); -} - -/** Non-interactive heading above a group of items. */ -export function ContextMenuLabel({ - className, - ...props -}: ComponentPropsWithRef< - typeof ContextMenuPrimitive.Label ->): React.JSX.Element { - return ( - - ); -} - -/** The row that opens its submenu; renders a trailing chevron. */ -export function ContextMenuSubTrigger({ - className, - children, - ...props -}: ComponentPropsWithRef< - typeof ContextMenuPrimitive.SubTrigger ->): React.JSX.Element { - return ( - - {children} - - - ); -} - -/** Thin rule between groups of items. */ -export function ContextMenuSeparator({ - className, - ...props -}: ComponentPropsWithRef< - typeof ContextMenuPrimitive.Separator ->): React.JSX.Element { - return ( - - ); -} diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx index 6368f27c13..afaee078b7 100644 --- a/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx +++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx @@ -1,6 +1,6 @@ -import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import { expect, waitFor } from "storybook/test"; -import { openSubmenuByKeyboard, PIXEL_ALL_THEMES } from "#storybook"; +import { openMenu, openSubmenuByKeyboard, PIXEL_ALL_THEMES } from "#storybook"; import { Button } from "../Button/Button"; import { Icon } from "../Icon/Icon"; @@ -76,15 +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 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 openMenu(canvasElement, "Workspace actions"); await openSubmenuByKeyboard("Open logs"); }, }; @@ -104,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 0f6e1b8101..92d3c4b093 100644 --- a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx +++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx @@ -2,23 +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"; -export { MenuKeybinding as DropdownMenuKeybinding } from "../menu"; +export { MenuKeybinding as DropdownMenuKeybinding } from "../Menu/Menu"; -/** Root state container; wraps the trigger and content. */ +/** 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, @@ -41,7 +83,7 @@ export function DropdownMenuContent({ ); } -/** The floating submenu surface, opened by `DropdownMenuSubTrigger`. */ +/** The floating submenu surface. */ export function DropdownMenuSubContent({ className, ...props @@ -60,112 +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 ( - - ); -} - -/** 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, - 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/IconButton/IconButton.css b/packages/ui/src/components/IconButton/IconButton.css index 0efa9dfc02..487981abc7 100644 --- a/packages/ui/src/components/IconButton/IconButton.css +++ b/packages/ui/src/components/IconButton/IconButton.css @@ -11,23 +11,19 @@ outline-offset: -1px; } -/* A toggled button keeps its active colors on hover, like native */ -.ui-icon-button:hover:not(:disabled, [aria-pressed="true"]) { +/* 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, [aria-pressed="true"]) { +.ui-icon-button:active:where(:not(:disabled, [aria-pressed="true"])) { background: var(--ui-toolbar-active-background); } -/* Native buttons outline on :focus, so a click shows it too */ -.ui-icon-button:focus { - 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); @@ -35,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/LoadingState/LoadingState.stories.tsx b/packages/ui/src/components/LoadingState/LoadingState.stories.tsx index 741d261f77..18f85f41c5 100644 --- a/packages/ui/src/components/LoadingState/LoadingState.stories.tsx +++ b/packages/ui/src/components/LoadingState/LoadingState.stories.tsx @@ -1,5 +1,3 @@ -import { expect, within } from "storybook/test"; - import { PIXEL_ALL_THEMES } from "#storybook"; import { LoadingState } from "./LoadingState"; @@ -10,6 +8,10 @@ const LoadingStates = (): React.JSX.Element => (
+
); @@ -21,8 +23,4 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const States: Story = { - play: async ({ canvasElement }) => { - await expect(within(canvasElement).getAllByRole("status")).toHaveLength(2); - }, -}; +export const States: Story = {}; diff --git a/packages/ui/src/components/LoadingState/LoadingState.tsx b/packages/ui/src/components/LoadingState/LoadingState.tsx index 28e676c579..a2494d5248 100644 --- a/packages/ui/src/components/LoadingState/LoadingState.tsx +++ b/packages/ui/src/components/LoadingState/LoadingState.tsx @@ -17,14 +17,14 @@ export interface LoadingStateProps extends Omit< /** Loading twin of the state panels, with the spinner in place of the icon. */ export function LoadingState({ description, - label = "Loading", + label, title, className, ...props }: LoadingStateProps): React.JSX.Element { return (
- + {title ?

{title}

: null} {description ? (
{description}
diff --git a/packages/ui/src/components/menu.css b/packages/ui/src/components/Menu/Menu.css similarity index 70% rename from packages/ui/src/components/menu.css rename to packages/ui/src/components/Menu/Menu.css index 3673deb7c5..85d024748e 100644 --- a/packages/ui/src/components/menu.css +++ b/packages/ui/src/components/Menu/Menu.css @@ -1,5 +1,4 @@ -/* Shared ContextMenu/DropdownMenu surface matching the native menu - widget; typography inherits from the webview body. */ +/* Shared ContextMenu/DropdownMenu surface, matching the native menu widget. */ .ui-menu { --ui-menu-gutter: 2em; --ui-overlay-border: var(--ui-menu-border); @@ -9,6 +8,7 @@ var(--radix-context-menu-content-available-height) ); + /* Native .monaco-menu min-width */ min-width: 160px; padding: 4px; color: var(--ui-menu-foreground); @@ -16,13 +16,18 @@ outline: 0; } -.ui-menu__item { - position: relative; +/* Rows and headings share their metrics so their labels stay aligned; the + gutters double as the check/icon columns. */ +.ui-menu__item, +.ui-menu__label { display: flex; align-items: center; height: 24px; - /* Native label inset; the gutters double as the check/icon columns. */ padding: 0 var(--ui-menu-gutter); +} + +.ui-menu__item { + position: relative; line-height: 1; white-space: nowrap; border-radius: var(--ui-radius-medium); @@ -32,8 +37,8 @@ outline-offset: -1px; } -/* Selection border only resolves in high contrast; an open submenu - keeps its parent highlighted, like native. */ +/* An open submenu keeps its parent highlighted, like native; the selection + border only resolves in high contrast. */ .ui-menu__item[data-highlighted], .ui-menu__item[data-state="open"] { forced-color-adjust: none; @@ -46,8 +51,8 @@ color: var(--ui-disabled-foreground); } -/* 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. */ +/* Left gutter, like the native menu-item-check; a checked item's check is + just another leading icon. */ .ui-menu__item > .ui-icon:not(.ui-menu__submenu-indicator) { position: absolute; left: 0; @@ -55,23 +60,18 @@ 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 */ +/* Native .keybinding: right-aligned, 1em inset, 0.7 opacity */ .ui-menu__keybinding { margin-inline-start: auto; padding-inline-start: 1em; opacity: 0.7; } -/* Chevron in the right gutter, like the native indicator */ +/* Right gutter, like the native submenu-indicator */ .ui-menu__submenu-indicator { position: absolute; right: 0; @@ -89,8 +89,8 @@ background: var(--ui-menu-separator); } -/* Entry fade like native menus. Gated on data-state so an interrupted - entry animation cannot block Radix from unmounting the closed menu. */ +/* Native fadeIn. Gated on data-state so an interrupted entry animation + cannot block Radix from unmounting the closed menu. */ @media (prefers-reduced-motion: no-preference) { .ui-menu:not([data-state="closed"]) { animation: ui-menu-fade-in 83ms linear; diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx new file mode 100644 index 0000000000..c219a66998 --- /dev/null +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -0,0 +1,71 @@ +import { cx } from "#cx"; + +import { formatKeybinding, type Keybinding } from "../../keybinding"; +import { Icon } from "../Icon/Icon"; + +import "./Menu.css"; + +import type { ComponentPropsWithRef, ElementType, ReactNode } from "react"; + +/** + * Keybinding hint inside a menu item; `keys` takes a keybindings + * contribution's fields and renders the current OS's native label style. + */ +export function MenuKeybinding({ + keys, + className, + ...props +}: Omit, "children"> & { + keys: Keybinding; +}): React.JSX.Element { + return ( + + {formatKeybinding(keys)} + + ); +} + +interface MenuPartOptions { + /** The primitive's `ItemIndicator`, which checks the item in the gutter. */ + indicator?: ElementType; + /** Appends the trailing submenu chevron. */ + chevron?: boolean; +} + +/** + * Applies a menu class to a Radix part. ContextMenu and DropdownMenu each own + * a separate Radix scope, so both build their parts from their own primitives. + */ +export function menuPart( + Part: T, + base: string, + { indicator: Indicator, chevron }: MenuPartOptions = {}, +): (props: ComponentPropsWithRef) => React.JSX.Element { + const Component: ElementType = Part; + + function MenuPart({ + className, + children, + ...props + }: { + className?: string; + children?: ReactNode; + }): React.JSX.Element { + return ( + + {Indicator ? ( + + + + ) : null} + {children} + {chevron ? ( + + ) : null} + + ); + } + MenuPart.displayName = base; + + return MenuPart; +} diff --git a/packages/ui/src/components/ProgressBar/ProgressBar.css b/packages/ui/src/components/ProgressBar/ProgressBar.css index 72334c14bc..dc31caec85 100644 --- a/packages/ui/src/components/ProgressBar/ProgressBar.css +++ b/packages/ui/src/components/ProgressBar/ProgressBar.css @@ -46,7 +46,7 @@ @media (forced-colors: active) { .ui-progress-bar__indicator { - /* Also repaints the reduced-motion stripes in the system color */ + /* Also repaints the reduced-motion stripes */ --ui-progress-foreground: Highlight; background: Highlight; diff --git a/packages/ui/src/components/Spinner/Spinner.css b/packages/ui/src/components/Spinner/Spinner.css index 189d208c88..023c91a016 100644 --- a/packages/ui/src/components/Spinner/Spinner.css +++ b/packages/ui/src/components/Spinner/Spinner.css @@ -28,8 +28,7 @@ } @media (prefers-reduced-motion: reduce) { - /* Tint the uncolored sides so the stopped ring reads as a paused - spinner, not a circle */ + /* Tint the uncolored sides so the stopped ring reads as a paused spinner */ .ui-spinner { --ui-spinner-track: color-mix( in srgb, @@ -45,6 +44,7 @@ @media (forced-colors: active) { .ui-spinner { + /* Overrides the reduced-motion tint too */ border-color: Canvas; border-block-start-color: Highlight; border-inline-end-color: Highlight; diff --git a/packages/ui/src/components/StatePanel/StatePanel.css b/packages/ui/src/components/StatePanel/StatePanel.css index d2eab995d4..5e2bfd244e 100644 --- a/packages/ui/src/components/StatePanel/StatePanel.css +++ b/packages/ui/src/components/StatePanel/StatePanel.css @@ -17,8 +17,8 @@ font-size: 2rem; } -/* The loading panel puts a spinner in the icon slot */ -.ui-state-panel > .ui-spinner { +/* The loading panel's spinner stands in for the icon */ +.ui-state-panel__spinner { margin-bottom: var(--ui-spacing-120); } diff --git a/packages/ui/src/components/Tooltip/Tooltip.css b/packages/ui/src/components/Tooltip/Tooltip.css index d5f7934e88..3c6b5b7a74 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.css +++ b/packages/ui/src/components/Tooltip/Tooltip.css @@ -1,5 +1,4 @@ -/* Matches the native workbench hover widget; typography inherits from - the webview body. */ +/* Matches the native workbench hover widget. */ .ui-tooltip { --ui-overlay-border: var(--ui-tooltip-border); /* Native hovers stop growing at half the window height */ @@ -12,7 +11,7 @@ padding: 4px 8px; color: var(--ui-tooltip-foreground); background: var(--ui-tooltip-background); - /* Native hover: 13px/19px type and a 5px radius, not cornerRadius-large */ + /* Native hover pins 19px leading and a literal 5px radius */ line-height: 19px; border-radius: 5px; overflow-wrap: break-word; diff --git a/packages/ui/src/components/Tooltip/Tooltip.stories.tsx b/packages/ui/src/components/Tooltip/Tooltip.stories.tsx index c78266db76..a1ec49a45d 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.stories.tsx +++ b/packages/ui/src/components/Tooltip/Tooltip.stories.tsx @@ -1,4 +1,4 @@ -import { expect, screen, userEvent, waitFor } from "storybook/test"; +import { screen, userEvent } from "storybook/test"; import { PIXEL_ALL_THEMES } from "#storybook"; @@ -33,37 +33,23 @@ const meta: Meta = { 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 b6a757ac2b..68a285c53a 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -13,9 +13,9 @@ export type TooltipProviderProps = ComponentProps< >; /** - * 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`. + * 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, diff --git a/packages/ui/src/components/control.css b/packages/ui/src/components/control.css index 6bdb935cec..b7d01eeaf5 100644 --- a/packages/ui/src/components/control.css +++ b/packages/ui/src/components/control.css @@ -1,6 +1,6 @@ -/* 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. */ +/* 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; @@ -11,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/menu.tsx b/packages/ui/src/components/menu.tsx deleted file mode 100644 index e8963ee0f4..0000000000 --- a/packages/ui/src/components/menu.tsx +++ /dev/null @@ -1,25 +0,0 @@ -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 6889f41fd3..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 */ diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 6b10979871..89b11ffb0a 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -65,7 +65,7 @@ export { formatKeybinding, type Keybinding, type KeybindingPlatform, -} from "./components/keybinding"; +} from "./keybinding"; export { Tooltip, type TooltipProps, diff --git a/packages/ui/src/components/keybinding.ts b/packages/ui/src/keybinding.ts similarity index 65% rename from packages/ui/src/components/keybinding.ts rename to packages/ui/src/keybinding.ts index e7bf4c0bb5..5ec4688f08 100644 --- a/packages/ui/src/components/keybinding.ts +++ b/packages/ui/src/keybinding.ts @@ -1,15 +1,15 @@ 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). + * A binding serialization, optionally overridden per platform, using the same + * fields as a `contributes.keybindings` entry. */ export type Keybinding = - string | { key?: string; mac?: string; win?: string; linux?: string }; + string | ({ key: string } & Partial>); -type Modifier = "ctrl" | "shift" | "alt" | "meta"; +const MODIFIER_ORDER = ["ctrl", "shift", "alt", "meta"] as const; -const MODIFIER_ORDER: readonly Modifier[] = ["ctrl", "shift", "alt", "meta"]; +type Modifier = (typeof MODIFIER_ORDER)[number]; /* Modifier tokens as VS Code serializes them, aliases included */ const MODIFIER_TOKENS: Readonly> = { @@ -54,34 +54,20 @@ function detectPlatform(): KeybindingPlatform { 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 { - 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); + return KEY_LABELS[token] ?? 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 tokens = chord.toLowerCase().split("+").filter(Boolean); + const modifiers = tokens.map((token) => MODIFIER_TOKENS[token]); const parts = MODIFIER_ORDER.filter((modifier) => - modifiers.has(modifier), + modifiers.includes(modifier), ).map((modifier) => labels[modifier]); + const key = tokens.findLast((token) => !MODIFIER_TOKENS[token]); if (key) { parts.push(keyLabel(key)); } @@ -92,8 +78,7 @@ function formatChord(chord: string, platform: KeybindingPlatform): string { * 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. + * key code uiMap). */ export function formatKeybinding( keybinding: Keybinding, @@ -103,9 +88,6 @@ export function formatKeybinding( typeof keybinding === "string" ? keybinding : (keybinding[platform] ?? keybinding.key); - if (!serialized) { - return ""; - } return serialized .trim() .split(/\s+/) 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 bc251be8fb..e799530fea 100644 --- a/packages/ui/src/storybook.tsx +++ b/packages/ui/src/storybook.ts @@ -1,4 +1,4 @@ -import { expect, screen, userEvent, waitFor } from "storybook/test"; +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; /** * Pixel matrix override (`parameters.pixel`) that snapshots a story in every @@ -10,6 +10,17 @@ export const PIXEL_ALL_THEMES = { }, } as const; +/* 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 { const menu = await screen.findByRole("menu"); diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index a45efc8918..17907b65ae 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -16,8 +16,7 @@ --vscode-sideBar-background, var(--vscode-editor-background) ); - /* For webviews hosted in an editor tab or the bottom panel; - --ui-background assumes the sidebar */ + /* For editor-tab and bottom-panel webviews; --ui-background assumes the sidebar */ --ui-panel-background: var( --vscode-panel-background, var(--vscode-editor-background) @@ -116,35 +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, VS Code's scale (baseSizes.ts); fallbacks predate - the host's corner radius tokens */ + /* 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) 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); + /* Spacing, VS Code's scale (baseSizes.ts); names are px times ten */ --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); diff --git a/packages/ui/src/useVscodeTheme.ts b/packages/ui/src/useVscodeTheme.ts index c953c7e378..342d71990b 100644 --- a/packages/ui/src/useVscodeTheme.ts +++ b/packages/ui/src/useVscodeTheme.ts @@ -7,6 +7,13 @@ export type VscodeThemeKind = 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>(); let observer: MutationObserver | undefined; @@ -31,30 +38,17 @@ function subscribe(onChange: () => void): () => void { }; } -function getThemeKind(): 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"; - } -} - -/* 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)}`; +/** ` `, 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 on any theme switch, including - * between two themes of the same kind, so token values read in JS never - * go stale. + * 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 { - useSyncExternalStore(subscribe, getSnapshot); - return getThemeKind(); + 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 9f81e8ab31..d37c2067f4 100644 --- a/packages/ui/src/vscode-parity.stories.tsx +++ b/packages/ui/src/vscode-parity.stories.tsx @@ -9,7 +9,6 @@ import { VscodeToolbarButton, } from "@vscode-elements/react-elements"; import { useState } from "react"; -import { screen, userEvent, within } from "storybook/test"; import { Button } from "./components/Button/Button"; import { @@ -25,7 +24,7 @@ import { ProgressBar } from "./components/ProgressBar/ProgressBar"; import { SearchInput } from "./components/SearchInput/SearchInput"; import { Spinner } from "./components/Spinner/Spinner"; 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"; @@ -230,9 +229,6 @@ 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"); + await openMenu(canvasElement, "Menu"); }, }; diff --git a/test/webview/ui/keybinding.test.ts b/test/webview/ui/keybinding.test.ts index 662a52d32a..0092ed395a 100644 --- a/test/webview/ui/keybinding.test.ts +++ b/test/webview/ui/keybinding.test.ts @@ -6,6 +6,7 @@ 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", () => { @@ -14,10 +15,17 @@ describe("formatKeybinding", () => { 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+shift+r" }; - expect(formatKeybinding(keys, "mac")).toBe("⇧⌘R"); + 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", () => { @@ -27,6 +35,16 @@ describe("formatKeybinding", () => { 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 2a8e7b2aad..3c95f84709 100644 --- a/test/webview/ui/useVscodeTheme.test.ts +++ b/test/webview/ui/useVscodeTheme.test.ts @@ -1,27 +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); - } -} - -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); +/** 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); - setThemeId(undefined); + // Unmount before clearing: the attribute change notifies the observer, and + // a still-mounted hook would then update outside act(). + cleanup(); + setTheme(); }); describe("useVscodeTheme", () => { @@ -31,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()); @@ -45,22 +46,21 @@ 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 () => { - setThemeKind("vscode-dark"); - setThemeId("vscode-dark-modern"); + setTheme("vscode-dark", "vscode-dark-modern"); let renders = 0; const { result } = renderHook(() => { @@ -69,9 +69,8 @@ describe("useVscodeTheme", () => { }); const rendersBefore = renders; - // MutationObserver callbacks are microtasks; flush them inside act. await act(async () => { - setThemeId("vscode-dark-plus"); + setTheme("vscode-dark", "vscode-dark-plus"); await Promise.resolve(); }); expect(renders).toBeGreaterThan(rendersBefore);