From 6f98256b3c3bc2ba08fdc0e0db7f62a7197ed1dc Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Thu, 16 Jul 2026 01:12:52 +0300 Subject: [PATCH 1/4] feat(ui): add Radix overlays themed to VS Code Tooltip, ContextMenu, and DropdownMenu wrap the Radix primitives with flat compound exports (Tooltip takes a content prop) and are styled to match the native menu and hover widgets via --ui-* tokens, including high contrast, forced-colors, and reduced-motion handling. Long menus and tooltips cap to the space Radix reports and scroll like native. The menu entry fade is gated on data-state so an interrupted animation cannot leave a closed overlay mounted. Stories snapshot the four theme families through Pixel and assert focus handling in play functions. --- .storybook/global.css | 23 +- packages/ui/README.md | 34 +- packages/ui/package.json | 9 +- .../ContextMenu/ContextMenu.stories.tsx | 111 +++ .../components/ContextMenu/ContextMenu.tsx | 96 +++ .../DropdownMenu/DropdownMenu.stories.tsx | 127 +++ .../components/DropdownMenu/DropdownMenu.tsx | 100 +++ .../ui/src/components/Tooltip/Tooltip.css | 13 + .../components/Tooltip/Tooltip.stories.tsx | 96 +++ .../ui/src/components/Tooltip/Tooltip.tsx | 46 ++ packages/ui/src/components/menu.css | 107 +++ packages/ui/src/components/overlay.css | 13 + packages/ui/src/index.ts | 23 + packages/ui/src/storybook.ts | 9 - packages/ui/src/storybook.tsx | 41 + packages/ui/src/tokens.css | 33 + pnpm-lock.yaml | 750 ++++++++++++++++++ pnpm-workspace.yaml | 3 + 18 files changed, 1618 insertions(+), 16 deletions(-) create mode 100644 packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx create mode 100644 packages/ui/src/components/ContextMenu/ContextMenu.tsx create mode 100644 packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx create mode 100644 packages/ui/src/components/DropdownMenu/DropdownMenu.tsx create mode 100644 packages/ui/src/components/Tooltip/Tooltip.css create mode 100644 packages/ui/src/components/Tooltip/Tooltip.stories.tsx create mode 100644 packages/ui/src/components/Tooltip/Tooltip.tsx create mode 100644 packages/ui/src/components/menu.css create mode 100644 packages/ui/src/components/overlay.css delete mode 100644 packages/ui/src/storybook.ts create mode 100644 packages/ui/src/storybook.tsx diff --git a/.storybook/global.css b/.storybook/global.css index 873c54f41e..bec24f95d6 100644 --- a/.storybook/global.css +++ b/.storybook/global.css @@ -1,6 +1,7 @@ /* Storybook stand-ins for what the VS Code host provides outside the webview document: the sidebar surface color and viewport sizing. - Typography and text color come from themes/generated/default-styles.css. */ + Typography and text color come from themes/generated/default-styles.css, + applied on body so portalled content (menus, tooltips) inherits it. */ html, #storybook-preview-wrapper { @@ -24,3 +25,23 @@ html body { needing a wider canvas override it via parameters.rootWidth */ width: 300px; } + +/* Story stand-in: a VS Code secondary button like consuming webviews render. */ +.story-trigger { + padding: 0.4em 1em; + border: 1px solid var(--vscode-button-border, transparent); + 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(--vscode-focusBorder); + outline-offset: 2px; +} diff --git a/packages/ui/README.md b/packages/ui/README.md index 03ed7566a1..0dc1ff9def 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -22,14 +22,36 @@ import "@repo/ui/codicon.css"; Component CSS is inherit-first: typography and text color come from the webview (`font: inherit`), and controls center content with a fixed height -plus the shared `.ui-control` flex base in `components/control.css` — never -with line-height or vertical padding math, which drifts off-center with -font metrics. +plus the shared `.ui-control` flex base in `components/control.css`. +Line-height and vertical padding math drift off-center with font metrics, +so components avoid them. Every component forwards `className` and `style` to its root element, and default rules use single-class specificity, so a consumer class imported after the library overrides any default (width, height, spacing). +## Overlays + +`Tooltip`, `ContextMenu`, and `DropdownMenu` wrap the Radix primitives and +are styled to match the native VS Code menu and hover widgets. The 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. Each `Tooltip` mounts its own +Radix provider, so the cross-trigger skip-delay window is not shared +between tooltips; if that matters, expose a shared provider. Checkbox/ +radio items, labels, and keybinding hints are not wrapped yet. + +Overlay content is portalled to `body` and inherits webview typography +from there. Both surfaces share the `.ui-overlay` base in +`components/overlay.css` for stacking, shadow, and scrolling; surface +colors come from the `--ui-menu-*` and `--ui-tooltip-*` tokens, and +corner radii from the generic `--ui-radius-*` tokens. Menus fade in like +native menus do, gated on `data-state` so an interrupted entry animation +cannot delay unmounting; tooltips appear instantly like the native hover +widget. High contrast follows the VS Code contrast variables, and the +styles handle `forced-colors` and `prefers-reduced-motion`. + ## Codicons `CodiconName` is derived directly from the installed @@ -39,8 +61,10 @@ without a generated source file or a runtime list in the public API. ## Isolation ESLint rejects `@repo/*` imports and relative cross-package imports in -`packages/ui` TypeScript and TSX source. `react` remains a peer dependency, and -public consumers import from the package root or its declared CSS exports. +`packages/ui` TypeScript and TSX source. `react` remains a peer dependency; +the only runtime dependencies are the Radix overlay primitives and +`@vscode/codicons`. Public consumers import from the package root or its +declared CSS exports. Shared internals are reached through `package.json` subpath imports (`#cx`, `#codicons`, `#storybook`). These resolve only inside this package and ship diff --git a/packages/ui/package.json b/packages/ui/package.json index 7497d9f8b0..14afcd8fff 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -4,6 +4,10 @@ "description": "Shared component library for VS Code webviews", "private": true, "type": "module", + "sideEffects": [ + "**/*.css", + "./storybook.preview.ts" + ], "exports": { ".": { "types": "./src/index.ts", @@ -15,12 +19,15 @@ "imports": { "#cx": "./src/cx.ts", "#codicons": "./src/codicons.ts", - "#storybook": "./src/storybook.ts" + "#storybook": "./src/storybook.tsx" }, "scripts": { "typecheck": "tsc --noEmit" }, "dependencies": { + "@radix-ui/react-context-menu": "catalog:", + "@radix-ui/react-dropdown-menu": "catalog:", + "@radix-ui/react-tooltip": "catalog:", "@vscode/codicons": "catalog:" }, "peerDependencies": { diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx new file mode 100644 index 0000000000..7975ea630a --- /dev/null +++ b/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx @@ -0,0 +1,111 @@ +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; + +import { overlaySpace, PIXEL_ALL_THEMES } from "#storybook"; + +import { Icon } from "../Icon/Icon"; + +import { + ContextMenu, + ContextMenuContent, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "./ContextMenu"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const TARGET_STYLE: React.CSSProperties = { + display: "grid", + placeItems: "center", + width: 240, + height: 120, + border: "1px dashed var(--ui-description-foreground)", +}; + +const MenuExample = (): React.JSX.Element => ( + + +
Right-click here
+
+ + + + Start workspace + + + + Restart + + + + Stop + + + + More actions + + Open logs + Edit settings + + + +
+); + +const meta: Meta = { + title: "UI/ContextMenu", + component: MenuExample, +}; + +export default meta; +type Story = StoryObj; + +/* Right-click at the target's center; without coords the contextmenu + event fires at (0,0) and the menu opens detached from the target. */ +async function rightClickCenter(target: Element): Promise { + const rect = target.getBoundingClientRect(); + await userEvent.pointer({ + keys: "[MouseRight]", + target, + coords: { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + }, + }); +} + +export const Closed: Story = {}; + +/* Opens the menu and its submenu so Pixel snapshots the open state. */ +export const Open: Story = { + decorators: [overlaySpace(620, 400)], + parameters: { pixel: PIXEL_ALL_THEMES }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await rightClickCenter(canvas.getByText("Right-click here")); + const menu = await screen.findByRole("menu"); + // Radix moves focus into the menu on open + await waitFor(() => + expect(menu.contains(document.activeElement)).toBe(true), + ); + // Keyboard avoids the submenu hover-open delay + await userEvent.keyboard("{End}{ArrowRight}"); + await screen.findByRole("menuitem", { name: "Open logs" }); + }, +}; + +export const EscapeClosesMenu: Story = { + parameters: { pixel: { exclude: true } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await rightClickCenter(canvas.getByText("Right-click here")); + await screen.findByRole("menu"); + await userEvent.keyboard("{Escape}"); + await waitFor(() => + expect(screen.queryByRole("menu")).not.toBeInTheDocument(), + ); + }, +}; diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.tsx new file mode 100644 index 0000000000..82c5f5be54 --- /dev/null +++ b/packages/ui/src/components/ContextMenu/ContextMenu.tsx @@ -0,0 +1,96 @@ +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; + +import { cx } from "#cx"; + +import { Icon } from "../Icon/Icon"; +import "../menu.css"; +import "../overlay.css"; + +import type { ComponentPropsWithRef } from "react"; + +export const ContextMenu = ContextMenuPrimitive.Root; +export const ContextMenuTrigger = ContextMenuPrimitive.Trigger; +export const ContextMenuGroup = ContextMenuPrimitive.Group; +export const ContextMenuSub = ContextMenuPrimitive.Sub; + +export function ContextMenuContent({ + className, + ...props +}: ComponentPropsWithRef< + typeof ContextMenuPrimitive.Content +>): React.JSX.Element { + return ( + + + + ); +} + +export function ContextMenuSubContent({ + className, + ...props +}: ComponentPropsWithRef< + typeof ContextMenuPrimitive.SubContent +>): React.JSX.Element { + return ( + + + + ); +} + +export function ContextMenuItem({ + className, + ...props +}: ComponentPropsWithRef): React.JSX.Element { + return ( + + ); +} + +export function ContextMenuSubTrigger({ + className, + children, + ...props +}: ComponentPropsWithRef< + typeof ContextMenuPrimitive.SubTrigger +>): React.JSX.Element { + return ( + + {children} + + + ); +} + +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 new file mode 100644 index 0000000000..c5f198dc01 --- /dev/null +++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx @@ -0,0 +1,127 @@ +import { expect, screen, userEvent, waitFor, within } from "storybook/test"; + +import { + overlaySpace, + PIXEL_ALL_THEMES, + STORY_TRIGGER_CLASS, +} from "#storybook"; + +import { Icon } from "../Icon/Icon"; + +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "./DropdownMenu"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const MenuExample = (): React.JSX.Element => ( + + + + + + + + Start workspace + + + + Restart + + + + Stop + + + + More actions + + Open logs + Edit settings + + + + +); + +const meta: Meta = { + title: "UI/DropdownMenu", + component: MenuExample, +}; + +export default meta; +type Story = StoryObj; + +export const Closed: Story = {}; + +/* Opens the menu and its submenu so Pixel snapshots the open state. */ +export const Open: Story = { + decorators: [overlaySpace(540, 320)], + parameters: { pixel: PIXEL_ALL_THEMES }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: "Workspace actions" }), + ); + const menu = await screen.findByRole("menu"); + // Radix moves focus into the menu on open + await waitFor(() => + expect(menu.contains(document.activeElement)).toBe(true), + ); + // Keyboard avoids the submenu hover-open delay + await userEvent.keyboard("{End}{ArrowRight}"); + await screen.findByRole("menuitem", { name: "Open logs" }); + }, +}; + +/* Long menus scroll. The default cap is the viewport space Radix + reports; the story lowers it via style. */ +export const ManyItems: Story = { + decorators: [overlaySpace(320, 300)], + render: () => ( + + + + + + {Array.from({ length: 40 }, (_, i) => ( + Workspace {i + 1} + ))} + + + ), + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + await userEvent.click( + canvas.getByRole("button", { name: "Workspace actions" }), + ); + const menu = await screen.findByRole("menu"); + await waitFor(() => + expect(menu.scrollHeight).toBeGreaterThan(menu.clientHeight), + ); + }, +}; + +export const FocusReturnsToTrigger: Story = { + parameters: { pixel: { exclude: true } }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const trigger = canvas.getByRole("button", { name: "Workspace actions" }); + await userEvent.click(trigger); + await screen.findByRole("menu"); + await userEvent.keyboard("{Escape}"); + await waitFor(() => expect(trigger).toHaveFocus()); + await expect(screen.queryByRole("menu")).not.toBeInTheDocument(); + }, +}; diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx new file mode 100644 index 0000000000..90a9f1c832 --- /dev/null +++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx @@ -0,0 +1,100 @@ +import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; + +import { cx } from "#cx"; + +import { Icon } from "../Icon/Icon"; +import "../menu.css"; +import "../overlay.css"; + +import type { ComponentPropsWithRef } from "react"; + +export const DropdownMenu = DropdownMenuPrimitive.Root; +export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +export const DropdownMenuGroup = DropdownMenuPrimitive.Group; +export const DropdownMenuSub = DropdownMenuPrimitive.Sub; + +export function DropdownMenuContent({ + className, + ...props +}: ComponentPropsWithRef< + typeof DropdownMenuPrimitive.Content +>): React.JSX.Element { + return ( + + + + ); +} + +export function DropdownMenuSubContent({ + className, + ...props +}: ComponentPropsWithRef< + typeof DropdownMenuPrimitive.SubContent +>): React.JSX.Element { + return ( + + + + ); +} + +export function DropdownMenuItem({ + className, + ...props +}: ComponentPropsWithRef< + typeof DropdownMenuPrimitive.Item +>): React.JSX.Element { + return ( + + ); +} + +export function DropdownMenuSubTrigger({ + className, + children, + ...props +}: ComponentPropsWithRef< + typeof DropdownMenuPrimitive.SubTrigger +>): React.JSX.Element { + return ( + + {children} + + + ); +} + +export function DropdownMenuSeparator({ + className, + ...props +}: ComponentPropsWithRef< + typeof DropdownMenuPrimitive.Separator +>): React.JSX.Element { + return ( + + ); +} diff --git a/packages/ui/src/components/Tooltip/Tooltip.css b/packages/ui/src/components/Tooltip/Tooltip.css new file mode 100644 index 0000000000..b87bb7bbe1 --- /dev/null +++ b/packages/ui/src/components/Tooltip/Tooltip.css @@ -0,0 +1,13 @@ +/* Matches the native workbench hover widget; typography inherits from + the webview body. */ +.ui-tooltip { + max-width: min(700px, calc(100vw - 16px)); + /* Cap to the space Radix reports; oversized content scrolls */ + max-height: var(--radix-tooltip-content-available-height); + padding: 4px 8px; + color: var(--ui-tooltip-foreground); + background: var(--ui-tooltip-background); + border: 1px solid var(--ui-tooltip-border); + border-radius: var(--ui-radius-large); + overflow-wrap: break-word; +} diff --git a/packages/ui/src/components/Tooltip/Tooltip.stories.tsx b/packages/ui/src/components/Tooltip/Tooltip.stories.tsx new file mode 100644 index 0000000000..fd6087c487 --- /dev/null +++ b/packages/ui/src/components/Tooltip/Tooltip.stories.tsx @@ -0,0 +1,96 @@ +import { expect, screen, userEvent, waitFor } from "storybook/test"; + +import { + overlaySpace, + PIXEL_ALL_THEMES, + STORY_TRIGGER_CLASS, +} from "#storybook"; + +import { Tooltip } from "./Tooltip"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const LONG_TEXT = + "This workspace has been running for 14 days without a rebuild. " + + "Stopping it frees compute resources, and any unsaved changes in " + + "the home volume are preserved until the next start."; + +const meta: Meta = { + title: "UI/Tooltip", + component: Tooltip, + args: { + content: "Stops the workspace", + // Instant in stories; the component default is 500ms + delayDuration: 0, + children: ( + + ), + }, +}; + +export default meta; +type Story = StoryObj; + +/* findByRole("tooltip") matches Radix's visually hidden a11y copy, so + open assertions target the visible .ui-tooltip bubble instead. */ +async function openTooltipWithKeyboard(): Promise { + 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 Closed: Story = {}; + +/* Keyboard focus opens instantly and proves keyboard users get the tooltip. */ +export const Open: Story = { + decorators: [overlaySpace(280, 100, { anchor: "bottom" })], + parameters: { pixel: PIXEL_ALL_THEMES }, + play: async () => { + await openTooltipWithKeyboard(); + }, +}; + +/* Long content wraps inside the native hover widget's max-width. */ +export const LongContent: Story = { + decorators: [overlaySpace(720, 160, { anchor: "bottom" })], + args: { + content: LONG_TEXT, + }, + play: async () => { + await openTooltipWithKeyboard(); + }, +}; + +/* Overflowing content scrolls inside the tooltip. The default cap is the + viewport space Radix reports; the story lowers it via style. */ +export const OverflowScrolls: Story = { + decorators: [overlaySpace(720, 240, { anchor: "bottom" })], + 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), + ); + }, +}; + +export const ClosesOnEscape: Story = { + parameters: { pixel: { exclude: true } }, + play: async () => { + await openTooltipWithKeyboard(); + await userEvent.keyboard("{Escape}"); + await waitFor(() => + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(), + ); + }, +}; diff --git a/packages/ui/src/components/Tooltip/Tooltip.tsx b/packages/ui/src/components/Tooltip/Tooltip.tsx new file mode 100644 index 0000000000..bf9fc780c6 --- /dev/null +++ b/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -0,0 +1,46 @@ +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; + +import { cx } from "#cx"; + +import "../overlay.css"; + +import "./Tooltip.css"; + +import type { ComponentPropsWithRef, ReactNode } from "react"; + +export interface TooltipProps extends Omit< + ComponentPropsWithRef, + "content" +> { + 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; +} + +export function Tooltip({ + content, + children, + className, + sideOffset = 4, + delayDuration = 500, + ...props +}: TooltipProps): React.JSX.Element { + return ( + + + {children} + + + {content} + + + + + ); +} diff --git a/packages/ui/src/components/menu.css b/packages/ui/src/components/menu.css new file mode 100644 index 0000000000..03e4c77d60 --- /dev/null +++ b/packages/ui/src/components/menu.css @@ -0,0 +1,107 @@ +/* Shared ContextMenu/DropdownMenu surface matching the native menu + widget; typography inherits from the webview body. */ +.ui-menu { + --ui-menu-gutter: 2em; + --ui-menu-highlight-foreground: var(--ui-menu-selection-foreground); + --ui-menu-highlight-background: var(--ui-menu-selection-background); + --ui-menu-disabled-foreground: var(--ui-disabled-foreground); + + min-width: 160px; + /* Cap to the space Radix reports (named per primitive) so long menus scroll */ + max-height: var( + --radix-dropdown-menu-content-available-height, + var(--radix-context-menu-content-available-height) + ); + padding: 4px; + color: var(--ui-menu-foreground); + background: var(--ui-menu-background); + border: 1px solid var(--ui-menu-border); + border-radius: var(--ui-radius-large); + outline: 0; +} + +.ui-menu__item { + position: relative; + display: flex; + align-items: center; + height: 24px; + /* Native label inset; the gutters double as the check/icon columns. */ + padding: 0 var(--ui-menu-gutter); + line-height: 1; + white-space: nowrap; + border-radius: var(--ui-radius-medium); + cursor: default; + user-select: none; + outline: 1px solid transparent; + outline-offset: -1px; +} + +/* Selection border only resolves in high contrast; an open submenu + keeps its parent highlighted, like native. */ +.ui-menu__item[data-highlighted], +.ui-menu__item[data-state="open"] { + forced-color-adjust: none; + color: var(--ui-menu-highlight-foreground); + background: var(--ui-menu-highlight-background); + outline-color: var(--ui-menu-selection-border); +} + +.ui-menu__item[data-disabled] { + color: var(--ui-menu-disabled-foreground); +} + +/* Leading icons sit in the left gutter so labels stay aligned */ +.ui-menu__item > .codicon:not(.ui-menu__submenu-indicator) { + position: absolute; + left: 0; + width: var(--ui-menu-gutter); + text-align: center; +} + +/* Chevron in the right gutter, like the native indicator */ +.ui-menu__submenu-indicator { + position: absolute; + right: 0; + width: var(--ui-menu-gutter); + text-align: center; +} + +.ui-menu__item[data-disabled] .ui-menu__submenu-indicator { + opacity: 0.4; +} + +.ui-menu__separator { + height: 1px; + margin: 5px 0; + 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. */ +.ui-menu:not([data-state="closed"]) { + animation: ui-menu-fade-in 83ms linear; +} + +@keyframes ui-menu-fade-in { + from { + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .ui-menu:not([data-state="closed"]) { + animation: none; + } +} + +@media (forced-colors: active) { + .ui-menu { + --ui-menu-highlight-foreground: HighlightText; + --ui-menu-highlight-background: Highlight; + --ui-menu-disabled-foreground: GrayText; + } + + .ui-menu__separator { + background: CanvasText; + } +} diff --git a/packages/ui/src/components/overlay.css b/packages/ui/src/components/overlay.css new file mode 100644 index 0000000000..2ce732be61 --- /dev/null +++ b/packages/ui/src/components/overlay.css @@ -0,0 +1,13 @@ +/* Shared base for portalled overlay surfaces (menus, tooltips). */ +.ui-overlay { + box-sizing: border-box; + overflow-y: auto; + box-shadow: var(--ui-widget-shadow); + z-index: 40; +} + +@media (forced-colors: active) { + .ui-overlay { + border-color: CanvasText; + } +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index bb84f45455..3a0ce65d4f 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -26,4 +26,27 @@ export { type StatusPillTone, } from "./components/StatusPill/StatusPill"; export type { CodiconName } from "./codicons"; +export { + ContextMenu, + ContextMenuContent, + ContextMenuGroup, + ContextMenuItem, + ContextMenuSeparator, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuTrigger, +} from "./components/ContextMenu/ContextMenu"; +export { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "./components/DropdownMenu/DropdownMenu"; +export { Tooltip, type TooltipProps } from "./components/Tooltip/Tooltip"; export { useVscodeTheme, type VscodeThemeKind } from "./useVscodeTheme"; diff --git a/packages/ui/src/storybook.ts b/packages/ui/src/storybook.ts deleted file mode 100644 index 7625fb663f..0000000000 --- a/packages/ui/src/storybook.ts +++ /dev/null @@ -1,9 +0,0 @@ -/** - * 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. - */ -export const PIXEL_ALL_THEMES = { - matrix: { - themes: ["light", "dark", "high-contrast", "high-contrast-light"], - }, -} as const; diff --git a/packages/ui/src/storybook.tsx b/packages/ui/src/storybook.tsx new file mode 100644 index 0000000000..d1d76332eb --- /dev/null +++ b/packages/ui/src/storybook.tsx @@ -0,0 +1,41 @@ +/** + * 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. + */ +export const PIXEL_ALL_THEMES = { + matrix: { + themes: ["light", "dark", "high-contrast", "high-contrast-light"], + }, +} as const; + +/* Story stand-in for a webview-styled button. */ +export const STORY_TRIGGER_CLASS = "story-trigger"; + +/* Reserves in-flow space for a portalled overlay so Pixel snapshots keep + it in frame; anchor "bottom" pins the trigger low for overlays that + open upward. */ +export function overlaySpace( + width: number, + height: number, + { anchor = "top" }: { anchor?: "top" | "bottom" } = {}, +): (Story: React.ComponentType) => React.JSX.Element { + const style: React.CSSProperties = + anchor === "bottom" + ? { + width, + height, + // Keeps the trigger's focus outline inside the cropped bounds + paddingBottom: 8, + display: "flex", + alignItems: "flex-end", + justifyContent: "center", + } + : { width, height }; + return function OverlaySpace(Story) { + return ( +
+ +
+ ); + }; +} diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index d2fa466503..8468336e49 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -90,6 +90,39 @@ var(--ui-foreground) ); + /* Disabled text */ + --ui-disabled-foreground: var(--vscode-disabledForeground); + + /* widget.shadow is null in high contrast, hiding the shadow like native */ + --ui-widget-shadow: 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); + + /* Menus */ + --ui-menu-background: var(--vscode-menu-background); + --ui-menu-foreground: var(--vscode-menu-foreground); + --ui-menu-border: var( + --vscode-menu-border, + var(--vscode-editorWidget-border, transparent) + ); + --ui-menu-selection-background: var( + --vscode-menu-selectionBackground, + transparent + ); + --ui-menu-selection-foreground: var( + --vscode-menu-selectionForeground, + var(--ui-menu-foreground) + ); + --ui-menu-selection-border: var(--vscode-menu-selectionBorder, transparent); + --ui-menu-separator: var(--vscode-menu-separatorBackground); + + /* Tooltip (native workbench hover widget) */ + --ui-tooltip-background: var(--vscode-editorHoverWidget-background); + --ui-tooltip-foreground: var(--vscode-editorHoverWidget-foreground); + --ui-tooltip-border: var(--vscode-editorHoverWidget-border); + /* Only defined by high contrast themes; transparent elsewhere */ --ui-contrast-border: var(--vscode-contrastBorder, transparent); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 375792c2bd..d5cf4eb540 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,6 +7,15 @@ settings: catalogs: default: + '@radix-ui/react-context-menu': + specifier: ^2.3.3 + version: 2.3.7 + '@radix-ui/react-dropdown-menu': + specifier: ^2.1.20 + version: 2.1.24 + '@radix-ui/react-tooltip': + specifier: ^1.2.12 + version: 1.2.16 '@rolldown/plugin-babel': specifier: ^0.2.3 version: 0.2.3 @@ -438,6 +447,15 @@ importers: packages/ui: dependencies: + '@radix-ui/react-context-menu': + specifier: 'catalog:' + version: 2.3.7(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-dropdown-menu': + specifier: 'catalog:' + version: 2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-tooltip': + specifier: 'catalog:' + version: 1.2.16(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) '@vscode/codicons': specifier: 'catalog:' version: 0.0.45 @@ -1032,6 +1050,21 @@ packages: '@noble/hashes': optional: true + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/react-dom@2.1.9': + resolution: {integrity: sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1528,6 +1561,311 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} + '@radix-ui/primitive@1.1.7': + resolution: {integrity: sha512-rqWnm76nYT8HoNNqEjpgJ7Pw/DrBj5iBTrmEPo6HTX5+VJyBNOqTdv4g89G63HuR5g0AaENoAcH7Is5fF2kZ8Q==} + + '@radix-ui/react-arrow@1.1.15': + resolution: {integrity: sha512-v4zggRcjadnI+ClKDuijlQEW4tw3NoaeHc/PwpKnLoLLKNUG4InLegkstooLcRIUWCs+8L22dGURCVuFfOKfnA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-collection@1.1.15': + resolution: {integrity: sha512-9W+B9NPF0NaaPh/1NJd3+KqsnlLqU9H7T2rvww+fp+T/evVXdNAyYcnfRQZFOjkR1ajQp3yORlqnI8soawLvNA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-compose-refs@1.1.5': + resolution: {integrity: sha512-+48PbAAbq3didjJxa+OaWY2ZwgAKsNiRGyeHKszblZMQ+kcpd9pAaT11cMkGEie0vsOi3QdeTE6d5Fe3Gn61kA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-context-menu@2.3.7': + resolution: {integrity: sha512-CtXP35dxaB5T3zXSd+E3uHe/QpXcpYnZmxp6OaIbfthtfW4wyb77M23BG+bwIJDtsMwEP/YssdsmNyZu7jhWew==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-context@1.2.2': + resolution: {integrity: sha512-RHCUGwKHDr0hDGg4X7ma4JG4/+12qxw8rkh5QKdDldlCvtja6nUx1Ef/8HVrJze81lEsgLQlqjzjGNHantgnQA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-direction@1.1.4': + resolution: {integrity: sha512-5pzg4FGQNpExhnhT2zlrP1wZFaYCd1K0nYWoFAdcYoYK868IEigqMX3B3f8yIoRlAhAeDWciLI6ZdCKHF9P4Vg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-dismissable-layer@1.1.19': + resolution: {integrity: sha512-8g4pfOL9HoKKLWGiypT+dphVqjFfmcXO5GBnhsG6zI+lxAx/8feQpr+1LSN8Re3hiZ+XkLNS4O9ztK11/LzQ6w==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-dropdown-menu@2.1.24': + resolution: {integrity: sha512-geq8l2rJkxvkXsT9RMgtUE3P8pITFpTsvYpbySi1IH4fZEABD/Gp85myayFgxk0ktljGMJnCbeFkyTusvSvv7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-focus-guards@1.1.6': + resolution: {integrity: sha512-RNOJjfZMTyBM6xYmV3IVGXkPjIhcBAuv48POevAXwrGJhkWZ9p1rFoIS1JFooPuT193AZmRsCPhpoVJxx6OPoQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-focus-scope@1.1.16': + resolution: {integrity: sha512-wmRZ2WWLvmt6KHy2rNPOdPUjwq5xOHY02+m+udwJTn0aNIox/rkskAvJTyTLGhPK6KgrUjlJUJpgmx/+wFiFIQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-id@1.1.4': + resolution: {integrity: sha512-TMQp2llA+RYn7JcjnrMnz7wN4pcVttPZnRZo52PLQsoLVKzNlVwUeHmfePgTgRluXFvlD3GD5g5MOVVTJCO0qA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-menu@2.1.24': + resolution: {integrity: sha512-uW7RVuU6Lp/ZtfeY4b3kL32zccgEWvPv1+cf17ubYzHa9cL8AHokmk36cG/XEiH/smbQvumnieXX9j/e9RqJWA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-popper@1.3.7': + resolution: {integrity: sha512-UsJrrd7w4wuKKTdvd/DNERVlwSlUcyXzjhyDwBk+3aPOsCjOY6ZSbxuw8E6lZTjjfP8Cpd0J8VVkrYUWyGYXyg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-portal@1.1.17': + resolution: {integrity: sha512-vKQLcWypUnwZVvfV7UkGahH2g6ySe8M8R+zYBwPrv5byZ9QAW6cQVvNKo7GgmD+p8aYb6D9JBuvy8/WhOno2wQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-presence@1.1.10': + resolution: {integrity: sha512-3wyzCQ6+ubRA+D4uv9m95JYLXxmOHp05qjrkjeA7uKHHtjpPggQzc6DAb0URl7j67oR0K2foO4ip27TiX037Bw==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-primitive@2.1.10': + resolution: {integrity: sha512-MucOnzh6hR5mid6VpkbglRAMYMjKLqRnGBbjXkzjK52fuQDd1qbkx78a5P40mkcnVXJdEVxm26E9OPAiUq7nBg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-roving-focus@1.1.19': + resolution: {integrity: sha512-V9jI6hDjT7l3jsCQD9bLNvDLM3tH/gdbOTp7Tefp3hbbgCGQoK7tUvrWiRlcoBHIZ809ElXwNQwVo0B98LuTXQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-slot@1.3.3': + resolution: {integrity: sha512-qx7oqnYbxnK9kYI9m317qmFmEgo6ywqWvbTogdj7cL9p3/yx4M48p7Rnw5z3H890cL/ow/EeWJsuTykeZVXP5Q==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-tooltip@1.2.16': + resolution: {integrity: sha512-6EamKFRRnlpdadndbZ6LMwycfwkwPte1B42hs6QA0gYhjaOKqW4PZ4pjaW9UrlDX5eVt/OjncE7BFTPL5nmZhg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/react-use-callback-ref@1.1.4': + resolution: {integrity: sha512-R6OUY2e2fA6Yn6s+VSx5KBV6Nx8LQEhu+cz7LCej18rQ1HLyg9PSC9jP/ZNx0o6FAIK9c0F1kHylzSxKsdlkrQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-controllable-state@1.2.6': + resolution: {integrity: sha512-uEQJGT97ZA/TgP/Hydw47lHu+/vQj6z/0jA+WeTbK1o9Rx45GImjpD0tc3W5ad3D6XTSR6e1yEO0FvGq6WQfVQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-effect-event@0.0.5': + resolution: {integrity: sha512-7cshFL8HGS/7HEiHH+9kL9HBwp2sa9yX18Knwek6KYWmXwM7pegMgta2AXMQKI+rq3JnfSj9x8wYqFMTdG1Jgg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-is-hydrated@0.1.3': + resolution: {integrity: sha512-umO/aJ+82CpOnhDZUTbILCQf7kU/g0iv+oGs/Q8jw7IkhWBzaEP4sA268PhFAJTFetbwp3ICc6ktpI4TqtxcIw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-layout-effect@1.1.4': + resolution: {integrity: sha512-K20DkRkUwDnxEYMBPcg3Y6voLkEy5p5QQmszZgLngKKiC7dzBR/aEuK3w1qlx2JWDUNH6FluahYdgR3BP+QbYw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-rect@1.1.4': + resolution: {integrity: sha512-cSOCh6JlkmfjLyNcLiu2nB4v+nm+dkZ+Q5KHWk/soo4U7ZLiEQFKHK9/YmtBHjfCEaU43IBKQOc4/uJmCaiCTQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-use-size@1.1.4': + resolution: {integrity: sha512-D3anSY15EJoxrihpsXI6SMrmmonnQtR2ni7arO+Lfdg3O95b9hNXxONk8jA5C8ANdF/h5HMAxejgs8PWJ6rlhw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + '@radix-ui/react-visually-hidden@1.2.11': + resolution: {integrity: sha512-NFS86RYYZb4/exihaESBGOpMJFz8MGLAfu3mOBSGByVnVPC9JPASfYubxd/8KbkQK0sYAv8lVQDEQukDX/qXvQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@radix-ui/rect@1.1.3': + resolution: {integrity: sha512-JtyZR+mqgBibTo8xea3B6ZRmzZiM/YeVBtUkas6zMuXjAlfIFIW2FgqeM9eLyvEaYX66vr6DJMK+4U6LV0KhNw==} + '@rolldown/binding-android-arm64@1.1.5': resolution: {integrity: sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2367,6 +2705,10 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-hidden@1.2.6: + resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==} + engines: {node: '>=10'} + aria-query@5.3.0: resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} @@ -2773,6 +3115,9 @@ packages: resolution: {integrity: sha512-qE3Veg1YXzGHQhlA6jzebZN2qVf6NX+A7m7qlhCGG30dJixrAQhYOsJjsnBjJkCSmuOPpCk30145fr8FV0bzog==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + detect-node@2.1.0: resolution: {integrity: sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==} @@ -3239,6 +3584,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-port-please@3.1.2: resolution: {integrity: sha512-Gxc29eLs1fbn6LQ4jSU4vXjlwyZhF5HsGuMAa7gqBP4Rw4yxxltyDUuF5MBclFzDTXO+ACchGQoeela4DSfzdQ==} @@ -4364,6 +4713,36 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -4936,6 +5315,26 @@ packages: url-join@4.0.1: resolution: {integrity: sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + use-sync-external-store@1.6.0: resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} peerDependencies: @@ -5794,6 +6193,23 @@ snapshots: '@exodus/bytes@1.15.1': {} + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/react-dom@2.1.9(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@floating-ui/dom': 1.8.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + + '@floating-ui/utils@0.2.12': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -6267,6 +6683,290 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true + '@radix-ui/primitive@1.1.7': {} + + '@radix-ui/react-arrow@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-context-menu@2.3.7(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-context@1.2.2(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-direction@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-dismissable-layer@1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-dropdown-menu@2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-menu': 2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-focus-guards@1.1.6(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-focus-scope@1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-id@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-menu@2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-focus-guards': 1.1.6(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-focus-scope': 1.1.16(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) + aria-hidden: 1.2.6 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-remove-scroll: 2.7.2(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-popper@1.3.7(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-arrow': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-rect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-size': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-portal@1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-slot@1.3.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-tooltip@1.2.16(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-dismissable-layer': 1.1.19(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-popper': 1.3.7(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-portal': 1.1.17(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-visually-hidden': 1.2.11(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/primitive': 1.1.7 + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-rect@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/rect': 1.1.3 + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-use-size@1.1.4(@types/react@19.2.17)(react@19.2.8)': + dependencies: + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + react: 19.2.8 + optionalDependencies: + '@types/react': 19.2.17 + + '@radix-ui/react-visually-hidden@1.2.11(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8)': + dependencies: + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + + '@radix-ui/rect@1.1.3': {} + '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -7189,6 +7889,10 @@ snapshots: argparse@2.0.1: {} + aria-hidden@1.2.6: + dependencies: + tslib: 2.8.1 + aria-query@5.3.0: dependencies: dequal: 2.0.3 @@ -7600,6 +8304,8 @@ snapshots: detect-newline@4.0.1: {} + detect-node-es@1.1.0: {} + detect-node@2.1.0: optional: true @@ -8158,6 +8864,8 @@ snapshots: hasown: 2.0.4 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-port-please@3.1.2: {} get-proto@1.0.1: @@ -9572,6 +10280,33 @@ snapshots: react-is@17.0.2: {} + react-remove-scroll-bar@2.3.8(@types/react@19.2.17)(react@19.2.8): + dependencies: + react: 19.2.8 + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.8) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + react-remove-scroll@2.7.2(@types/react@19.2.17)(react@19.2.8): + dependencies: + react: 19.2.8 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.17)(react@19.2.8) + react-style-singleton: 2.2.3(@types/react@19.2.17)(react@19.2.8) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.17)(react@19.2.8) + use-sidecar: 1.1.3(@types/react@19.2.17)(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.17 + + react-style-singleton@2.2.3(@types/react@19.2.17)(react@19.2.8): + dependencies: + get-nonce: 1.0.1 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + react@19.2.8: {} read-pkg@9.0.1: @@ -10208,6 +10943,21 @@ snapshots: url-join@4.0.1: {} + use-callback-ref@1.3.3(@types/react@19.2.17)(react@19.2.8): + dependencies: + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + + use-sidecar@1.1.3(@types/react@19.2.17)(react@19.2.8): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.8 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.17 + use-sync-external-store@1.6.0(react@19.2.8): dependencies: react: 19.2.8 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0d6f484a73..f54e8f49d6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,6 +2,9 @@ packages: - packages/* catalog: + "@radix-ui/react-context-menu": ^2.3.3 + "@radix-ui/react-dropdown-menu": ^2.1.20 + "@radix-ui/react-tooltip": ^1.2.12 "@rolldown/plugin-babel": ^0.2.3 "@storybook/addon-a11y": ^10.5.5 "@storybook/addon-docs": ^10.5.5 From 0e985dccfa9980cb27626bfbb94c314c07b60d3e Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Mon, 27 Jul 2026 21:14:38 +0300 Subject: [PATCH 2/4] fix(storybook): grow story root to fit reserved overlay space --- .storybook/global.css | 6 ++++++ packages/ui/src/storybook.tsx | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/.storybook/global.css b/.storybook/global.css index bec24f95d6..437b3248eb 100644 --- a/.storybook/global.css +++ b/.storybook/global.css @@ -26,6 +26,12 @@ html body { width: 300px; } +/* Grow the root to fit overlaySpace reservations so Pixel's autofit + crop keeps portalled overlays in frame. */ +#root:has([data-overlay-space]) { + width: max-content; +} + /* Story stand-in: a VS Code secondary button like consuming webviews render. */ .story-trigger { padding: 0.4em 1em; diff --git a/packages/ui/src/storybook.tsx b/packages/ui/src/storybook.tsx index d1d76332eb..a7694aad1a 100644 --- a/packages/ui/src/storybook.tsx +++ b/packages/ui/src/storybook.tsx @@ -33,7 +33,7 @@ export function overlaySpace( : { width, height }; return function OverlaySpace(Story) { return ( -
+
); From 1db5c9af44812d1c9010e241086fb1f786cd933b Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 28 Jul 2026 15:59:29 +0300 Subject: [PATCH 3/4] refactor(ui): simplify overlays and match native VS Code widgets - Menu highlight uses list.hover* and tooltips sit flush left like native - .ui-overlay owns border, radius, shadow, and available-height scrolling - Replace overlaySpace reservations with an afterEach root fit in preview - Trim stories, add JSDoc to menu parts, drop unused Group exports - Inline Radix deps in packages/ui and pin codicons 0.0.46-24 --- .storybook/global.css | 26 -------- .storybook/preview.ts | 46 +++++++++++++- packages/ui/README.md | 45 ++++++++------ packages/ui/package.json | 6 +- .../ContextMenu/ContextMenu.stories.tsx | 62 +++++-------------- .../components/ContextMenu/ContextMenu.tsx | 11 +++- .../DropdownMenu/DropdownMenu.stories.tsx | 42 ++----------- .../components/DropdownMenu/DropdownMenu.tsx | 11 +++- .../ui/src/components/Tooltip/Tooltip.css | 12 ++-- .../components/Tooltip/Tooltip.stories.tsx | 39 ++---------- .../ui/src/components/Tooltip/Tooltip.tsx | 5 +- packages/ui/src/components/menu.css | 42 ++++++------- packages/ui/src/components/overlay.css | 8 ++- packages/ui/src/index.ts | 2 - packages/ui/src/storybook.css | 20 ++++++ packages/ui/src/storybook.tsx | 39 ++++-------- packages/ui/src/tokens.css | 17 +++-- pnpm-lock.yaml | 49 ++++++--------- pnpm-workspace.yaml | 5 +- 19 files changed, 217 insertions(+), 270 deletions(-) create mode 100644 packages/ui/src/storybook.css diff --git a/.storybook/global.css b/.storybook/global.css index 437b3248eb..520f14665e 100644 --- a/.storybook/global.css +++ b/.storybook/global.css @@ -25,29 +25,3 @@ html body { needing a wider canvas override it via parameters.rootWidth */ width: 300px; } - -/* Grow the root to fit overlaySpace reservations so Pixel's autofit - crop keeps portalled overlays in frame. */ -#root:has([data-overlay-space]) { - width: max-content; -} - -/* Story stand-in: a VS Code secondary button like consuming webviews render. */ -.story-trigger { - padding: 0.4em 1em; - border: 1px solid var(--vscode-button-border, transparent); - 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(--vscode-focusBorder); - outline-offset: 2px; -} diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 49eedb8258..51d3c6c9f3 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -70,10 +70,54 @@ function applyTheme(requested: string): void { document.body.setAttribute("data-vscode-theme-kind", `vscode-${slug}`); } +/* Pixel's autofit crop follows in-flow layout, but portalled overlays + (menus, tooltips) are out of flow and would be cropped away. Grow the + story root to cover any element portalled to body. Relies on the + padded (top-left anchored) layout: growth only extends right and + down, so already-positioned overlays never move. */ +function fitRootToPortals(): void { + const root = document.getElementById("root"); + if (!root) { + return; + } + const origin = root.getBoundingClientRect(); + let right = 0; + let bottom = 0; + for (const el of document.body.children) { + // Skip Storybook chrome (root, loaders, error display, a11y helpers) + if ( + !(el instanceof HTMLElement) || + el.id.startsWith("storybook-") || + el.classList.contains("sb-wrapper") + ) { + continue; + } + const rect = el.getBoundingClientRect(); + if (rect.width === 0 || rect.height === 0) { + continue; + } + right = Math.max(right, rect.right - origin.left); + bottom = Math.max(bottom, rect.bottom - origin.top); + } + if (right > 0 && bottom > 0) { + // Slack keeps shadows and focus outlines in frame + root.style.minWidth = `${Math.ceil(right) + 16}px`; + root.style.minHeight = `${Math.ceil(bottom) + 16}px`; + } +} + const preview: Preview = { parameters: { - layout: "centered", + // Top-left anchored; fitRootToPortals depends on this + layout: "padded", + }, + beforeEach: () => { + // Undo fitRootToPortals sizing when dev story switches reuse the root + const root = document.getElementById("root"); + root?.style.removeProperty("min-width"); + root?.style.removeProperty("min-height"); }, + afterEach: fitRootToPortals, globalTypes: { theme: { description: "Global theme for components", diff --git a/packages/ui/README.md b/packages/ui/README.md index 0dc1ff9def..ed6b7d1416 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -32,25 +32,32 @@ after the library overrides any default (width, height, spacing). ## Overlays -`Tooltip`, `ContextMenu`, and `DropdownMenu` wrap the Radix primitives and -are styled to match the native VS Code menu and hover widgets. The 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. Each `Tooltip` mounts its own -Radix provider, so the cross-trigger skip-delay window is not shared -between tooltips; if that matters, expose a shared provider. Checkbox/ -radio items, labels, and keybinding hints are not wrapped yet. - -Overlay content is portalled to `body` and inherits webview typography -from there. Both surfaces share the `.ui-overlay` base in -`components/overlay.css` for stacking, shadow, and scrolling; surface -colors come from the `--ui-menu-*` and `--ui-tooltip-*` tokens, and -corner radii from the generic `--ui-radius-*` tokens. Menus fade in like -native menus do, gated on `data-state` so an interrupted entry animation -cannot delay unmounting; tooltips appear instantly like the native hover -widget. High contrast follows the VS Code contrast variables, and the -styles handle `forced-colors` and `prefers-reduced-motion`. +`Tooltip`, `ContextMenu`, and `DropdownMenu` wrap the Radix primitives, +styled to match the native VS Code menu and hover widgets. Menus expose +Radix's compound parts as flat named exports (`DropdownMenuTrigger`, +`DropdownMenuItem`, …); `Tooltip` is a single component taking a `content` +prop, with a 500ms show delay matching VS Code's `workbench.hover.delay` +default. + +Overlay content is portalled to `body`, inherits webview typography from +there, and shares the `.ui-overlay` base for stacking, border, shadow, +and scrolling. Menus fade in like native menus, gated on `data-state` so +an interrupted entry animation cannot delay unmounting. High contrast, +`forced-colors`, and `prefers-reduced-motion` are handled. + +Known gaps, fine to fix later: + +- 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. ## Codicons diff --git a/packages/ui/package.json b/packages/ui/package.json index 14afcd8fff..7d902b0bf0 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -25,9 +25,9 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@radix-ui/react-context-menu": "catalog:", - "@radix-ui/react-dropdown-menu": "catalog:", - "@radix-ui/react-tooltip": "catalog:", + "@radix-ui/react-context-menu": "^2.3.7", + "@radix-ui/react-dropdown-menu": "^2.1.24", + "@radix-ui/react-tooltip": "^1.2.16", "@vscode/codicons": "catalog:" }, "peerDependencies": { diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx index 7975ea630a..aa7814c504 100644 --- a/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx +++ b/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx @@ -1,6 +1,6 @@ -import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import { userEvent, within } from "storybook/test"; -import { overlaySpace, PIXEL_ALL_THEMES } from "#storybook"; +import { openSubmenuByKeyboard, PIXEL_ALL_THEMES } from "#storybook"; import { Icon } from "../Icon/Icon"; @@ -35,10 +35,6 @@ const MenuExample = (): React.JSX.Element => ( Start workspace - - - Restart - Stop @@ -63,49 +59,21 @@ const meta: Meta = { export default meta; type Story = StoryObj; -/* Right-click at the target's center; without coords the contextmenu - event fires at (0,0) and the menu opens detached from the target. */ -async function rightClickCenter(target: Element): Promise { - const rect = target.getBoundingClientRect(); - await userEvent.pointer({ - keys: "[MouseRight]", - target, - coords: { - clientX: rect.left + rect.width / 2, - clientY: rect.top + rect.height / 2, - }, - }); -} - -export const Closed: Story = {}; - -/* Opens the menu and its submenu so Pixel snapshots the open state. */ export const Open: Story = { - decorators: [overlaySpace(620, 400)], parameters: { pixel: PIXEL_ALL_THEMES }, play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await rightClickCenter(canvas.getByText("Right-click here")); - const menu = await screen.findByRole("menu"); - // Radix moves focus into the menu on open - await waitFor(() => - expect(menu.contains(document.activeElement)).toBe(true), - ); - // Keyboard avoids the submenu hover-open delay - await userEvent.keyboard("{End}{ArrowRight}"); - await screen.findByRole("menuitem", { name: "Open logs" }); - }, -}; - -export const EscapeClosesMenu: Story = { - parameters: { pixel: { exclude: true } }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - await rightClickCenter(canvas.getByText("Right-click here")); - await screen.findByRole("menu"); - await userEvent.keyboard("{Escape}"); - await waitFor(() => - expect(screen.queryByRole("menu")).not.toBeInTheDocument(), - ); + const target = within(canvasElement).getByText("Right-click here"); + /* Right-click at the target's center; without coords the menu + opens at (0,0), detached from the target. */ + const rect = target.getBoundingClientRect(); + await userEvent.pointer({ + keys: "[MouseRight]", + target, + coords: { + clientX: rect.left + rect.width / 2, + clientY: rect.top + rect.height / 2, + }, + }); + await openSubmenuByKeyboard("Open logs"); }, }; diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.tsx index 82c5f5be54..3a6ea7c058 100644 --- a/packages/ui/src/components/ContextMenu/ContextMenu.tsx +++ b/packages/ui/src/components/ContextMenu/ContextMenu.tsx @@ -8,11 +8,16 @@ import "../overlay.css"; import type { ComponentPropsWithRef } from "react"; +/** Root state container; wraps the trigger and content. */ export const ContextMenu = ContextMenuPrimitive.Root; + +/** The right-click target area; renders its child element via `asChild`. */ export const ContextMenuTrigger = ContextMenuPrimitive.Trigger; -export const ContextMenuGroup = ContextMenuPrimitive.Group; + +/** Scopes one submenu; wraps its sub trigger and sub content. */ export const ContextMenuSub = ContextMenuPrimitive.Sub; +/** The floating menu surface, portalled to `body` at the pointer. */ export function ContextMenuContent({ className, ...props @@ -32,6 +37,7 @@ export function ContextMenuContent({ ); } +/** The floating submenu surface, opened by `ContextMenuSubTrigger`. */ export function ContextMenuSubContent({ className, ...props @@ -51,6 +57,7 @@ export function ContextMenuSubContent({ ); } +/** One selectable action row; a leading `Icon` sits in the gutter. */ export function ContextMenuItem({ className, ...props @@ -63,6 +70,7 @@ export function ContextMenuItem({ ); } +/** The row that opens its submenu; renders a trailing chevron. */ export function ContextMenuSubTrigger({ className, children, @@ -81,6 +89,7 @@ export function ContextMenuSubTrigger({ ); } +/** Thin rule between groups of items. */ export function ContextMenuSeparator({ className, ...props diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx index c5f198dc01..2eef395388 100644 --- a/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx +++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx @@ -1,7 +1,7 @@ import { expect, screen, userEvent, waitFor, within } from "storybook/test"; import { - overlaySpace, + openSubmenuByKeyboard, PIXEL_ALL_THEMES, STORY_TRIGGER_CLASS, } from "#storybook"; @@ -33,10 +33,6 @@ const MenuExample = (): React.JSX.Element => ( Start workspace - - - Restart - Stop @@ -61,32 +57,18 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Closed: Story = {}; - -/* Opens the menu and its submenu so Pixel snapshots the open state. */ export const Open: Story = { - decorators: [overlaySpace(540, 320)], parameters: { pixel: PIXEL_ALL_THEMES }, play: async ({ canvasElement }) => { - const canvas = within(canvasElement); await userEvent.click( - canvas.getByRole("button", { name: "Workspace actions" }), - ); - const menu = await screen.findByRole("menu"); - // Radix moves focus into the menu on open - await waitFor(() => - expect(menu.contains(document.activeElement)).toBe(true), + within(canvasElement).getByRole("button", { name: "Workspace actions" }), ); - // Keyboard avoids the submenu hover-open delay - await userEvent.keyboard("{End}{ArrowRight}"); - await screen.findByRole("menuitem", { name: "Open logs" }); + await openSubmenuByKeyboard("Open logs"); }, }; -/* Long menus scroll. The default cap is the viewport space Radix - reports; the story lowers it via style. */ +/* Long menus cap to the viewport by default; the story lowers the cap. */ export const ManyItems: Story = { - decorators: [overlaySpace(320, 300)], render: () => ( @@ -102,9 +84,8 @@ export const ManyItems: Story = { ), play: async ({ canvasElement }) => { - const canvas = within(canvasElement); await userEvent.click( - canvas.getByRole("button", { name: "Workspace actions" }), + within(canvasElement).getByRole("button", { name: "Workspace actions" }), ); const menu = await screen.findByRole("menu"); await waitFor(() => @@ -112,16 +93,3 @@ export const ManyItems: Story = { ); }, }; - -export const FocusReturnsToTrigger: Story = { - parameters: { pixel: { exclude: true } }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const trigger = canvas.getByRole("button", { name: "Workspace actions" }); - await userEvent.click(trigger); - await screen.findByRole("menu"); - await userEvent.keyboard("{Escape}"); - await waitFor(() => expect(trigger).toHaveFocus()); - await expect(screen.queryByRole("menu")).not.toBeInTheDocument(); - }, -}; diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx index 90a9f1c832..b02daab587 100644 --- a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx +++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx @@ -8,11 +8,16 @@ import "../overlay.css"; import type { ComponentPropsWithRef } from "react"; +/** Root state container; wraps the trigger and content. */ export const DropdownMenu = DropdownMenuPrimitive.Root; + +/** Opens the menu on click; renders its child element via `asChild`. */ export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; -export const DropdownMenuGroup = DropdownMenuPrimitive.Group; + +/** Scopes one submenu; wraps its sub trigger and sub content. */ export const DropdownMenuSub = DropdownMenuPrimitive.Sub; +/** The floating menu surface, portalled to `body`. */ export function DropdownMenuContent({ className, ...props @@ -34,6 +39,7 @@ export function DropdownMenuContent({ ); } +/** The floating submenu surface, opened by `DropdownMenuSubTrigger`. */ export function DropdownMenuSubContent({ className, ...props @@ -53,6 +59,7 @@ export function DropdownMenuSubContent({ ); } +/** One selectable action row; a leading `Icon` sits in the gutter. */ export function DropdownMenuItem({ className, ...props @@ -67,6 +74,7 @@ export function DropdownMenuItem({ ); } +/** The row that opens its submenu; renders a trailing chevron. */ export function DropdownMenuSubTrigger({ className, children, @@ -85,6 +93,7 @@ export function DropdownMenuSubTrigger({ ); } +/** Thin rule between groups of items. */ export function DropdownMenuSeparator({ className, ...props diff --git a/packages/ui/src/components/Tooltip/Tooltip.css b/packages/ui/src/components/Tooltip/Tooltip.css index b87bb7bbe1..54f651fae4 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.css +++ b/packages/ui/src/components/Tooltip/Tooltip.css @@ -1,13 +1,15 @@ /* Matches the native workbench hover widget; typography inherits from the webview body. */ .ui-tooltip { - max-width: min(700px, calc(100vw - 16px)); - /* Cap to the space Radix reports; oversized content scrolls */ - max-height: var(--radix-tooltip-content-available-height); + --ui-overlay-border: var(--ui-tooltip-border); + --ui-overlay-available-height: var(--radix-tooltip-content-available-height); + + max-width: min(700px, var(--radix-tooltip-content-available-width)); padding: 4px 8px; color: var(--ui-tooltip-foreground); background: var(--ui-tooltip-background); - border: 1px solid var(--ui-tooltip-border); - border-radius: var(--ui-radius-large); + /* Native hover: 13px/19px type and a 5px radius, not cornerRadius-large */ + 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 fd6087c487..4166157b30 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.stories.tsx +++ b/packages/ui/src/components/Tooltip/Tooltip.stories.tsx @@ -1,10 +1,6 @@ import { expect, screen, userEvent, waitFor } from "storybook/test"; -import { - overlaySpace, - PIXEL_ALL_THEMES, - STORY_TRIGGER_CLASS, -} from "#storybook"; +import { PIXEL_ALL_THEMES, STORY_TRIGGER_CLASS } from "#storybook"; import { Tooltip } from "./Tooltip"; @@ -46,32 +42,16 @@ async function openTooltipWithKeyboard(): Promise { return tooltip; } -export const Closed: Story = {}; - -/* Keyboard focus opens instantly and proves keyboard users get the tooltip. */ export const Open: Story = { - decorators: [overlaySpace(280, 100, { anchor: "bottom" })], parameters: { pixel: PIXEL_ALL_THEMES }, play: async () => { await openTooltipWithKeyboard(); }, }; -/* Long content wraps inside the native hover widget's max-width. */ -export const LongContent: Story = { - decorators: [overlaySpace(720, 160, { anchor: "bottom" })], - args: { - content: LONG_TEXT, - }, - play: async () => { - await openTooltipWithKeyboard(); - }, -}; - -/* Overflowing content scrolls inside the tooltip. The default cap is the - viewport space Radix reports; the story lowers it via style. */ -export const OverflowScrolls: Story = { - decorators: [overlaySpace(720, 240, { anchor: "bottom" })], +/* Long content wraps at the hover width cap and scrolls once the story + lowers the height cap. */ +export const Overflow: Story = { args: { content: Array.from({ length: 8 }, () => LONG_TEXT).join(" "), style: { maxHeight: 160 }, @@ -83,14 +63,3 @@ export const OverflowScrolls: Story = { ); }, }; - -export const ClosesOnEscape: Story = { - parameters: { pixel: { exclude: true } }, - play: async () => { - await openTooltipWithKeyboard(); - await userEvent.keyboard("{Escape}"); - await waitFor(() => - expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(), - ); - }, -}; diff --git a/packages/ui/src/components/Tooltip/Tooltip.tsx b/packages/ui/src/components/Tooltip/Tooltip.tsx index bf9fc780c6..fc3ca6d061 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -23,7 +23,6 @@ export function Tooltip({ content, children, className, - sideOffset = 4, delayDuration = 500, ...props }: TooltipProps): React.JSX.Element { @@ -33,8 +32,10 @@ export function Tooltip({ {children} {content} diff --git a/packages/ui/src/components/menu.css b/packages/ui/src/components/menu.css index 03e4c77d60..935590dfc1 100644 --- a/packages/ui/src/components/menu.css +++ b/packages/ui/src/components/menu.css @@ -2,21 +2,17 @@ widget; typography inherits from the webview body. */ .ui-menu { --ui-menu-gutter: 2em; - --ui-menu-highlight-foreground: var(--ui-menu-selection-foreground); - --ui-menu-highlight-background: var(--ui-menu-selection-background); - --ui-menu-disabled-foreground: var(--ui-disabled-foreground); - - min-width: 160px; - /* Cap to the space Radix reports (named per primitive) so long menus scroll */ - max-height: var( + --ui-overlay-border: var(--ui-menu-border); + /* Radix names the available-height variable per primitive */ + --ui-overlay-available-height: var( --radix-dropdown-menu-content-available-height, var(--radix-context-menu-content-available-height) ); + + min-width: 160px; padding: 4px; color: var(--ui-menu-foreground); background: var(--ui-menu-background); - border: 1px solid var(--ui-menu-border); - border-radius: var(--ui-radius-large); outline: 0; } @@ -47,11 +43,11 @@ } .ui-menu__item[data-disabled] { - color: var(--ui-menu-disabled-foreground); + color: var(--ui-disabled-foreground); } /* Leading icons sit in the left gutter so labels stay aligned */ -.ui-menu__item > .codicon:not(.ui-menu__submenu-indicator) { +.ui-menu__item > .ui-icon:not(.ui-menu__submenu-indicator) { position: absolute; left: 0; width: var(--ui-menu-gutter); @@ -67,7 +63,7 @@ } .ui-menu__item[data-disabled] .ui-menu__submenu-indicator { - opacity: 0.4; + opacity: var(--ui-disabled-opacity); } .ui-menu__separator { @@ -78,8 +74,10 @@ /* Entry fade like native menus. Gated on data-state so an interrupted entry animation cannot block Radix from unmounting the closed menu. */ -.ui-menu:not([data-state="closed"]) { - animation: ui-menu-fade-in 83ms linear; +@media (prefers-reduced-motion: no-preference) { + .ui-menu:not([data-state="closed"]) { + animation: ui-menu-fade-in 83ms linear; + } } @keyframes ui-menu-fade-in { @@ -88,17 +86,15 @@ } } -@media (prefers-reduced-motion: reduce) { - .ui-menu:not([data-state="closed"]) { - animation: none; +@media (forced-colors: active) { + .ui-menu__item[data-highlighted], + .ui-menu__item[data-state="open"] { + color: HighlightText; + background: Highlight; } -} -@media (forced-colors: active) { - .ui-menu { - --ui-menu-highlight-foreground: HighlightText; - --ui-menu-highlight-background: Highlight; - --ui-menu-disabled-foreground: GrayText; + .ui-menu__item[data-disabled] { + color: GrayText; } .ui-menu__separator { diff --git a/packages/ui/src/components/overlay.css b/packages/ui/src/components/overlay.css index 2ce732be61..f7e6c5e041 100644 --- a/packages/ui/src/components/overlay.css +++ b/packages/ui/src/components/overlay.css @@ -1,7 +1,13 @@ -/* Shared base for portalled overlay surfaces (menus, tooltips). */ +/* 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. */ .ui-overlay { box-sizing: border-box; + /* Cap to the space Radix reports so oversized content scrolls */ + max-height: var(--ui-overlay-available-height, none); overflow-y: auto; + border: 1px solid var(--ui-overlay-border, transparent); + border-radius: var(--ui-radius-large); box-shadow: var(--ui-widget-shadow); z-index: 40; } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 3a0ce65d4f..a62e0e3b93 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -29,7 +29,6 @@ export type { CodiconName } from "./codicons"; export { ContextMenu, ContextMenuContent, - ContextMenuGroup, ContextMenuItem, ContextMenuSeparator, ContextMenuSub, @@ -40,7 +39,6 @@ export { export { DropdownMenu, DropdownMenuContent, - DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuSub, diff --git a/packages/ui/src/storybook.css b/packages/ui/src/storybook.css new file mode 100644 index 0000000000..181ac87273 --- /dev/null +++ b/packages/ui/src/storybook.css @@ -0,0 +1,20 @@ +/* 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 a7694aad1a..06957f165b 100644 --- a/packages/ui/src/storybook.tsx +++ b/packages/ui/src/storybook.tsx @@ -1,3 +1,7 @@ +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. @@ -8,34 +12,13 @@ export const PIXEL_ALL_THEMES = { }, } as const; -/* Story stand-in for a webview-styled button. */ +/* Story stand-in for a webview-styled button; styled in storybook.css. */ export const STORY_TRIGGER_CLASS = "story-trigger"; -/* Reserves in-flow space for a portalled overlay so Pixel snapshots keep - it in frame; anchor "bottom" pins the trigger low for overlays that - open upward. */ -export function overlaySpace( - width: number, - height: number, - { anchor = "top" }: { anchor?: "top" | "bottom" } = {}, -): (Story: React.ComponentType) => React.JSX.Element { - const style: React.CSSProperties = - anchor === "bottom" - ? { - width, - height, - // Keeps the trigger's focus outline inside the cropped bounds - paddingBottom: 8, - display: "flex", - alignItems: "flex-end", - justifyContent: "center", - } - : { width, height }; - return function OverlaySpace(Story) { - return ( -
- -
- ); - }; +/* 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"); + await waitFor(() => expect(menu.contains(document.activeElement)).toBe(true)); + await userEvent.keyboard("{End}{ArrowRight}"); + await screen.findByRole("menuitem", { name: itemName }); } diff --git a/packages/ui/src/tokens.css b/packages/ui/src/tokens.css index 8468336e49..6ab9b09e1c 100644 --- a/packages/ui/src/tokens.css +++ b/packages/ui/src/tokens.css @@ -93,8 +93,12 @@ /* Disabled text */ --ui-disabled-foreground: var(--vscode-disabledForeground); - /* widget.shadow is null in high contrast, hiding the shadow like native */ - --ui-widget-shadow: 0 0 12px var(--vscode-widget-shadow, transparent); + /* Mirrors the native menu shadow; widget.shadow is null in high + contrast, hiding the shadow like native */ + --ui-widget-shadow: var( + --vscode-shadow-lg, + 0 0 12px var(--vscode-widget-shadow, transparent) + ); /* Corner radii; fallbacks predate the host's corner radius tokens */ --ui-radius-large: var(--vscode-cornerRadius-large, 5px); @@ -107,12 +111,13 @@ --vscode-menu-border, var(--vscode-editorWidget-border, transparent) ); - --ui-menu-selection-background: var( - --vscode-menu-selectionBackground, + /* Native menus highlight with the list hover colors (defaultMenuStyles) */ + --ui-menu-highlight-background: var( + --vscode-list-hoverBackground, transparent ); - --ui-menu-selection-foreground: var( - --vscode-menu-selectionForeground, + --ui-menu-highlight-foreground: var( + --vscode-list-hoverForeground, var(--ui-menu-foreground) ); --ui-menu-selection-border: var(--vscode-menu-selectionBorder, transparent); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5cf4eb540..c2c5c89b62 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,15 +7,6 @@ settings: catalogs: default: - '@radix-ui/react-context-menu': - specifier: ^2.3.3 - version: 2.3.7 - '@radix-ui/react-dropdown-menu': - specifier: ^2.1.20 - version: 2.1.24 - '@radix-ui/react-tooltip': - specifier: ^1.2.12 - version: 1.2.16 '@rolldown/plugin-babel': specifier: ^0.2.3 version: 0.2.3 @@ -47,8 +38,8 @@ catalogs: specifier: ^2.4.0 version: 2.4.0 '@vscode/codicons': - specifier: ^0.0.45 - version: 0.0.45 + specifier: 0.0.46-24 + version: 0.0.46-24 babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -222,7 +213,7 @@ importers: version: 4.1.10(vitest@4.1.10) '@vscode/codicons': specifier: 'catalog:' - version: 0.0.45 + version: 0.0.46-24 '@vscode/test-cli': specifier: ^0.0.15 version: 0.0.15 @@ -403,10 +394,10 @@ importers: version: 5.101.4(react@19.2.8) '@vscode-elements/react-elements': specifier: 'catalog:' - version: 2.4.0(@types/react@19.2.17)(@vscode/codicons@0.0.45)(react-dom@19.2.8)(react@19.2.8) + version: 2.4.0(@types/react@19.2.17)(@vscode/codicons@0.0.46-24)(react-dom@19.2.8)(react@19.2.8) '@vscode/codicons': specifier: 'catalog:' - version: 0.0.45 + version: 0.0.46-24 date-fns: specifier: 'catalog:' version: 4.4.0 @@ -448,24 +439,24 @@ importers: packages/ui: dependencies: '@radix-ui/react-context-menu': - specifier: 'catalog:' + specifier: ^2.3.7 version: 2.3.7(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) '@radix-ui/react-dropdown-menu': - specifier: 'catalog:' + specifier: ^2.1.24 version: 2.1.24(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) '@radix-ui/react-tooltip': - specifier: 'catalog:' + specifier: ^1.2.16 version: 1.2.16(@types/react-dom@19.2.3)(@types/react@19.2.17)(react-dom@19.2.8)(react@19.2.8) '@vscode/codicons': specifier: 'catalog:' - version: 0.0.45 + version: 0.0.46-24 devDependencies: '@types/react': specifier: 'catalog:' version: 19.2.17 '@vscode-elements/react-elements': specifier: 'catalog:' - version: 2.4.0(@types/react@19.2.17)(@vscode/codicons@0.0.45)(react-dom@19.2.8)(react@19.2.8) + version: 2.4.0(@types/react@19.2.17)(@vscode/codicons@0.0.46-24)(react-dom@19.2.8)(react@19.2.8) react: specifier: 'catalog:' version: 19.2.8 @@ -490,7 +481,7 @@ importers: version: 1.57.5 '@vscode-elements/react-elements': specifier: 'catalog:' - version: 2.4.0(@types/react@19.2.17)(@vscode/codicons@0.0.45)(react-dom@19.2.8)(react@19.2.8) + version: 2.4.0(@types/react@19.2.17)(@vscode/codicons@0.0.46-24)(react-dom@19.2.8)(react@19.2.8) react: specifier: 'catalog:' version: 19.2.8 @@ -511,10 +502,10 @@ importers: version: 5.101.4(react@19.2.8) '@vscode-elements/react-elements': specifier: 'catalog:' - version: 2.4.0(@types/react@19.2.17)(@vscode/codicons@0.0.45)(react-dom@19.2.8)(react@19.2.8) + version: 2.4.0(@types/react@19.2.17)(@vscode/codicons@0.0.46-24)(react-dom@19.2.8)(react@19.2.8) '@vscode/codicons': specifier: 'catalog:' - version: 0.0.45 + version: 0.0.46-24 date-fns: specifier: 'catalog:' version: 4.4.0 @@ -2582,8 +2573,8 @@ packages: react: 17 || 18 || 19 react-dom: 17 || 18 || 19 - '@vscode/codicons@0.0.45': - resolution: {integrity: sha512-1KAZ7XCMagp5Gdrlr4bbbcAqgcIL623iO1wW6rfcSVGAVUQvR0WP7bQx1SbJ11gmV3fdQTSEFIJQ/5C+HuVasw==} + '@vscode/codicons@0.0.46-24': + resolution: {integrity: sha512-KIDWUYxG6eh+DHRg+3G4bS487T+g1ZkLfB4HylOBbCcHsvLdfLMRM/m92DS1Ljhi8nglLr9ucwwWhHTEAC+DpA==} '@vscode/test-cli@0.0.15': resolution: {integrity: sha512-nAxk2X79wuXS7aOhyFFhFcCqd7EBUoMesu7ZgsYE/4eFjyBMuyIweVE94BxdKH1RieN8eOz2SIrljrZt6Lk9fQ==} @@ -7722,23 +7713,23 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 - '@vscode-elements/elements@2.5.1(@vscode/codicons@0.0.45)': + '@vscode-elements/elements@2.5.1(@vscode/codicons@0.0.46-24)': dependencies: '@lit/context': 1.1.6 - '@vscode/codicons': 0.0.45 + '@vscode/codicons': 0.0.46-24 lit: 3.3.3 - '@vscode-elements/react-elements@2.4.0(@types/react@19.2.17)(@vscode/codicons@0.0.45)(react-dom@19.2.8)(react@19.2.8)': + '@vscode-elements/react-elements@2.4.0(@types/react@19.2.17)(@vscode/codicons@0.0.46-24)(react-dom@19.2.8)(react@19.2.8)': dependencies: '@lit/react': 1.0.8(@types/react@19.2.17) - '@vscode-elements/elements': 2.5.1(@vscode/codicons@0.0.45) + '@vscode-elements/elements': 2.5.1(@vscode/codicons@0.0.46-24) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) transitivePeerDependencies: - '@types/react' - '@vscode/codicons' - '@vscode/codicons@0.0.45': {} + '@vscode/codicons@0.0.46-24': {} '@vscode/test-cli@0.0.15': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f54e8f49d6..df06481594 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,9 +2,6 @@ packages: - packages/* catalog: - "@radix-ui/react-context-menu": ^2.3.3 - "@radix-ui/react-dropdown-menu": ^2.1.20 - "@radix-ui/react-tooltip": ^1.2.12 "@rolldown/plugin-babel": ^0.2.3 "@storybook/addon-a11y": ^10.5.5 "@storybook/addon-docs": ^10.5.5 @@ -15,7 +12,7 @@ catalog: "@types/vscode-webview": ^1.57.5 "@vitejs/plugin-react": ^6.0.4 "@vscode-elements/react-elements": ^2.4.0 - "@vscode/codicons": ^0.0.45 + "@vscode/codicons": 0.0.46-24 babel-plugin-react-compiler: ^1.0.0 coder: github:coder/coder#v2.34.1 date-fns: ^4.4.0 From de10ce37cb2251ef56042fd35e96cd10e8cca01e Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Tue, 28 Jul 2026 16:15:12 +0300 Subject: [PATCH 4/4] fix(ui): tighten Pixel crops, declare react-dom peer, expand known gaps - Shrink #storybook-root to fit-content; the padded layout rendered it as a full-width block, making every Pixel snapshot viewport-wide - Declare react-dom as a peer dependency (the Radix primitives need it) - Extend the README known gaps to cover the whole package, not just the overlays --- .storybook/global.css | 6 ++++++ packages/ui/README.md | 26 +++++++++++++++++++++++++- packages/ui/package.json | 4 +++- pnpm-lock.yaml | 3 +++ 4 files changed, 37 insertions(+), 2 deletions(-) diff --git a/.storybook/global.css b/.storybook/global.css index 520f14665e..77cea19e46 100644 --- a/.storybook/global.css +++ b/.storybook/global.css @@ -19,6 +19,12 @@ html body { overflow: auto; } +/* Shrink the root from a full-width block to its content so Pixel's + autofit crop stays tight. */ +#storybook-root { + width: fit-content; +} + #root { max-width: 100%; /* arbitrary size choice for the rough VSCode sidebar size; stories diff --git a/packages/ui/README.md b/packages/ui/README.md index ed6b7d1416..bc548386f4 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -45,7 +45,11 @@ and scrolling. Menus fade in like native menus, gated on `data-state` so an interrupted entry animation cannot delay unmounting. High contrast, `forced-colors`, and `prefers-reduced-motion` are handled. -Known gaps, fine to fix later: +## Known gaps + +Deliberate deferrals, fine to fix later. + +Overlays: - Menus only support plain action items; Radix's checkbox/radio items, group labels, and keybinding hints have no styled wrappers yet. @@ -59,6 +63,26 @@ Known gaps, fine to fix later: - A very tall tooltip fills most of the viewport before it scrolls, where native hovers stop at half the window height. +Package-wide: + +- There is no `Button`; the VS Code button style exists only inside the + state panels, and secondary-button colors have no `--ui-*` tokens. +- Only the Empty and Error panels ship; a Loading panel would need the + shared panel skeleton, which stays internal. +- The token layer maps what shipped components need: there are no + list/selection-row, spacing, typography, or z-index tokens, and the + `--ui-radius-*` tokens are only adopted by the overlays, with older + controls hardcoding their radii. +- `--ui-background` assumes a sidebar webview; a webview hosted in an + editor tab or panel renders on the sidebar color. +- `useVscodeTheme` reports the theme kind only; switching between two + themes of the same kind does not notify subscribers. +- Under `prefers-reduced-motion` the indeterminate `ProgressBar` renders + as a full bar and the `Spinner` as a static ring, with no other + activity cue. +- Story helpers compile against root-hoisted Storybook packages; a + standalone split needs its own Storybook devDependencies. + ## Codicons `CodiconName` is derived directly from the installed diff --git a/packages/ui/package.json b/packages/ui/package.json index 7d902b0bf0..8f64bb0e86 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -31,12 +31,14 @@ "@vscode/codicons": "catalog:" }, "peerDependencies": { - "react": "catalog:" + "react": "catalog:", + "react-dom": "catalog:" }, "devDependencies": { "@types/react": "catalog:", "@vscode-elements/react-elements": "catalog:", "react": "catalog:", + "react-dom": "catalog:", "typescript": "catalog:" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c2c5c89b62..543215dd05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -460,6 +460,9 @@ importers: react: specifier: 'catalog:' version: 19.2.8 + react-dom: + specifier: 'catalog:' + version: 19.2.8(react@19.2.8) typescript: specifier: 'catalog:' version: 6.0.3