From 21fc063655354c107c7b180f44d03787a774534c Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Wed, 23 Sep 2026 11:43:15 +0300 Subject: [PATCH] refactor(ui): move the overlay components from Radix to Base UI Base UI serves the menu, context menu, select, and tooltip from one package, and its context menu reuses the menu's own parts. ContextMenu and DropdownMenu now share one set of Menu* items, keeping only their root, trigger, and surface. Every positioner names its available-height, anchor-width, and transform-origin variables the same way, so the per-surface CSS mappings collapse into one overlay rule. Styling and behavior stay as they were, verified by measuring both builds side by side. Where Base UI differs, this change corrects it: - Every overlay positions with a fixed strategy. A fixed positioner gets its own layer, which keeps the text on greyscale antialiasing instead of subpixel fringing. - The select list and context menu align to their anchor's start, and the context menu sets its side explicitly to skip Base UI's context-menu offsets. - The select list scrolls a viewport inside the padded panel, so the last row sits against the padding rather than under the corner radius. - Select.Icon renders a literal arrow as default children, so the trigger renders the codicon directly. - Base UI sets no tooltip role or aria-describedby and names a select option from its whole contents, so the tooltip regains both and an option is labelled by its text rather than its description. The workarounds Radix needed are gone: TooltipScope, since a Base UI tooltip works without a provider; the jsdom pointer-capture and scrollIntoView stubs; and the tree's hidden hover anchor span. Tree rows still hand their hovers to one shared bubble, because a Base UI trigger per row costs about five times as much to mount. The bubble now positions against a virtual anchor measured from the tree. The hover delegate types are no longer exported. Only Tree uses them, so the public tooltip API is Tooltip and TooltipProvider. The overlay APIs are stricter than the Radix ones were: - MenuContent and SelectContent forward every prop to the menu or listbox element, and take only side, align, and offsets for placement. - Select requires items, since Base UI builds the trigger label from them and would otherwise show the raw value, and takes a single value. - MenuLabel builds on Menu.GroupLabel and names the MenuGroup or MenuRadioGroup it sits in. Stories drive a component into the state Pixel photographs, so their play functions only interact and wait. The checks nothing else covered moved to unit tests. --- .storybook/preview.ts | 4 +- packages/ui/README.md | 50 +- packages/ui/package.json | 6 +- .../ContextMenu/ContextMenu.stories.tsx | 55 +- .../components/ContextMenu/ContextMenu.tsx | 100 +-- .../DropdownMenu/DropdownMenu.stories.tsx | 79 +- .../components/DropdownMenu/DropdownMenu.tsx | 107 +-- .../ErrorState/ErrorState.stories.tsx | 3 +- .../ui/src/components/Icon/Icon.stories.tsx | 7 - .../IconButton/IconButton.stories.tsx | 4 +- .../src/components/IconButton/IconButton.tsx | 8 +- packages/ui/src/components/Menu/Menu.css | 26 +- packages/ui/src/components/Menu/Menu.tsx | 186 +++- .../src/components/Menu/MenuExampleItems.tsx | 47 + .../ProgressBar/ProgressBar.stories.tsx | 12 +- .../SearchInput/SearchInput.stories.tsx | 4 +- packages/ui/src/components/Select/Select.css | 17 +- .../src/components/Select/Select.stories.tsx | 102 ++- packages/ui/src/components/Select/Select.tsx | 75 +- .../components/Spinner/Spinner.stories.tsx | 8 +- .../ui/src/components/Tooltip/Tooltip.css | 8 +- .../components/Tooltip/Tooltip.stories.tsx | 26 +- .../ui/src/components/Tooltip/Tooltip.tsx | 194 ++-- packages/ui/src/components/Tree/Tree.css | 7 - .../ui/src/components/Tree/Tree.stories.tsx | 57 +- packages/ui/src/components/Tree/Tree.tsx | 124 ++- packages/ui/src/components/Tree/TreeHover.tsx | 100 +-- packages/ui/src/components/overlay.css | 12 +- packages/ui/src/cx.ts | 8 + packages/ui/src/index.ts | 38 +- packages/ui/src/storybook.ts | 22 +- packages/ui/src/vscode-parity.stories.tsx | 72 +- pnpm-lock.yaml | 840 ++---------------- pnpm-workspace.yaml | 1 + test/webview/setup.ts | 5 - test/webview/ui/components.test.tsx | 21 +- test/webview/ui/forms.test.tsx | 41 +- test/webview/ui/overlays.test.tsx | 124 +++ test/webview/ui/tree.hover.test.tsx | 2 +- test/webview/ui/tree.keyboard.test.tsx | 2 +- test/webview/ui/tree.rows.test.tsx | 4 +- 41 files changed, 948 insertions(+), 1660 deletions(-) create mode 100644 packages/ui/src/components/Menu/MenuExampleItems.tsx create mode 100644 test/webview/ui/overlays.test.tsx diff --git a/.storybook/preview.ts b/.storybook/preview.ts index ef464fb77a..4fc426ab08 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -91,9 +91,7 @@ function fitRootToPortals(): void { const origin = root.getBoundingClientRect(); let right = 0; let bottom = 0; - for (const overlay of document.querySelectorAll( - "[data-radix-popper-content-wrapper]", - )) { + for (const overlay of document.querySelectorAll(".ui-overlay")) { const rect = overlay.getBoundingClientRect(); right = Math.max(right, rect.right - origin.left); bottom = Math.max(bottom, rect.bottom - origin.top); diff --git a/packages/ui/README.md b/packages/ui/README.md index 74ffecbc26..f52479e635 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -157,7 +157,9 @@ styling. Labels hover with the node's text value, so truncated rows stay readable. Set `tooltip` for richer content or `null` to opt out. One bubble serves the -whole tree, as in the native list: an invisible anchor moves to whatever the +whole tree, as in the native list: every `Tooltip` inside the tree, including +those of row actions, hands its target to that bubble instead of mounting its +own, so a large tree stays cheap to render. The bubble moves to whatever the pointer reaches, taking its x from the cursor and its y from the target's box, the way a native hover placed at the mouse does. Each new target waits out the show delay, except within a row's action bar, where crossing between buttons is @@ -166,39 +168,35 @@ row's hover with no delay at all, and moving the focus closes it. ## Overlays -`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`, `DropdownMenuCheckboxItem`, …): checkbox and radio -items show a check in the icon gutter, `*Label` renders a group heading, and -`*Keybinding` renders a shortcut hint. Pass `keys` the same `key`/`mac`/ +`Tooltip`, `ContextMenu`, and `DropdownMenu` wrap the Base UI primitives, +styled to match the native VS Code menu and hover widgets. Both menus share +flat named `Menu*` parts (`MenuItem`, `MenuCheckboxItem`, `MenuSub`, …) +inside their own `*Trigger` and `*Content`: checkbox and radio items show a +check in the icon gutter, `MenuLabel` inside a `MenuGroup` or +`MenuRadioGroup` renders its heading and names it, and `MenuKeybinding` +renders a shortcut hint. `*Content` takes `side`, `align`, and offsets for +placement; every other prop, including `ref` and `aria-*`, lands on the menu +element. Pass `keys` the same `key`/`mac`/ `win`/`linux` fields as a keybindings contribution to get the current OS's binding in its native label style (`⇧⌘R` on macOS, `Ctrl+Shift+R` elsewhere); `formatKeybinding` does the same for other surfaces, such as tooltips. -`Tooltip` is a single component taking a `content` prop, and requires a -`TooltipProvider` ancestor. Mount one provider per app so that a pointer +`Tooltip` is a single component taking a `content` prop. Mount one +`TooltipProvider` per app so that a pointer moving between nearby triggers skips the show delay, like native hovers. The delay defaults to 500ms, matching VS Code's `workbench.hover.delay`, -and tooltips stop growing at half the window height. Components that own -their hovers fall back to a private provider when the app has none, so -`Tree` rows and `IconButton` work unwrapped. A private provider keeps its own -skip-delay, though, so an app with several of them makes every hover wait out -the full delay; mount one provider and they share it. `IconButton` hints with +and tooltips stop growing at half the window height. Without a provider, +`Tree` rows and `IconButton` still work, but every hover waits out the full +delay. `IconButton` hints with its label like a native action bar item; pass `tooltip` to say something else, or `null` for a button that stays quiet. -`HoverDelegateScope` hands every `Tooltip` inside it to one shared bubble -instead of a bubble each, the way a VS Code list serves its rows and their -action bars from a single hover widget. `Tree` uses it, which is also what -lets one place decide when a hover is instant rather than delayed. - Overlay content is portalled to `body`, inherits webview typography from there, and shares the `.ui-overlay` base for stacking, border, shadow, scrolling, and highlighted rows. Menus default to the Modern UI motion: they scale and fade in from the trigger corner and fade out on close, with -Radix holding unmount until the exit animation ends. High contrast, `forced-colors`, and +Base UI holding unmount until the exit animation ends. High contrast, `forced-colors`, and `prefers-reduced-motion` are handled. ## Form controls @@ -213,10 +211,12 @@ stacked `Checkbox` controls inside a `Field`. `Input` and `Textarea` are controlled with `value` and `onChange(next)`; `Checkbox` uses `checked` and `onChange(next)`. Native-element props and refs pass through to the control; `className` and `style` target the root. -`Select` wraps `@radix-ui/react-select` and preserves its controlled -(`value` / `onValueChange`) and uncontrolled (`defaultValue`) modes, with -flat compound exports such as `SelectTrigger` and `SelectItem`, as the -menus do. +`Select` wraps the Base UI select for a single value and preserves its +controlled (`value` / `onValueChange`) and uncontrolled (`defaultValue`) +modes, with flat compound exports such as `SelectTrigger` and `SelectItem`, +as the menus do. It requires `items`, the labels the trigger shows for each +value. `SelectContent` props other than `className` and `style` land on the +listbox. `Input` renders `children` after the control for trailing in-field actions; `PasswordInput` uses that slot for a reveal toggle styled like the find widget's option buttons. @@ -249,7 +249,7 @@ without a generated source file or a runtime list in the public API. ESLint rejects `@repo/*` imports and relative cross-package imports in `packages/ui` TypeScript and TSX source. `react` remains a peer dependency; -the only runtime dependencies are the Radix primitives and +the only runtime dependencies are `@base-ui/react` and `@vscode/codicons`. Public consumers import from the package root or its declared CSS exports. diff --git a/packages/ui/package.json b/packages/ui/package.json index f027f307c2..7d96f308e3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -26,11 +26,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@radix-ui/react-context-menu": "^2.3.7", - "@radix-ui/react-dropdown-menu": "^2.1.24", - "@radix-ui/react-select": "^2.3.7", - "@radix-ui/react-slot": "^1.3.3", - "@radix-ui/react-tooltip": "^1.2.16", + "@base-ui/react": "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 index c1eac7498a..13dc65df7f 100644 --- a/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx +++ b/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx @@ -1,22 +1,12 @@ import { userEvent, within } from "storybook/test"; -import { openSubmenuByKeyboard, PIXEL_ALL_THEMES } from "#storybook"; +import { highlightRow, PIXEL_ALL_THEMES } from "#storybook"; -import { Icon } from "../Icon/Icon"; +import { MenuExampleItems } from "../Menu/MenuExampleItems"; import { ContextMenu, - ContextMenuCheckboxItem, ContextMenuContent, - ContextMenuItem, - ContextMenuKeybinding, - ContextMenuLabel, - ContextMenuRadioGroup, - ContextMenuRadioItem, - ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, ContextMenuTrigger, } from "./ContextMenu"; @@ -32,42 +22,11 @@ const TARGET_STYLE: React.CSSProperties = { const MenuExample = (): React.JSX.Element => ( - -
Right-click here
-
+ Right-click here} + /> - - - Start workspace - - - - Stop - - - Rebuild - - - - - Start on connect - - - Sort by - - Name - Status - - - - More actions - - Open logs - Edit settings - - +
); @@ -95,6 +54,6 @@ export const Open: Story = { clientY: rect.top + rect.height / 2, }, }); - await openSubmenuByKeyboard("Open logs"); + await highlightRow("Open logs"); }, }; diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.tsx index 93f877f3ec..6386f78897 100644 --- a/packages/ui/src/components/ContextMenu/ContextMenu.tsx +++ b/packages/ui/src/components/ContextMenu/ContextMenu.tsx @@ -1,102 +1,12 @@ -import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { ContextMenu as ContextMenuPrimitive } from "@base-ui/react/context-menu"; -import { cx } from "#cx"; +import { MenuContent, type MenuContentProps } from "../Menu/Menu"; -import { menuPart } from "../Menu/Menu"; -import "../Menu/Menu.css"; -import "../overlay.css"; - -import type { ComponentPropsWithRef } from "react"; - -export { MenuKeybinding as ContextMenuKeybinding } from "../Menu/Menu"; - -/** Root state container. */ export const ContextMenu = ContextMenuPrimitive.Root; -/** The right-click target area; renders its child element via `asChild`. */ export const ContextMenuTrigger = ContextMenuPrimitive.Trigger; -/** Scopes one submenu. */ -export const ContextMenuSub = ContextMenuPrimitive.Sub; - -/** Groups radio items into one exclusive selection. */ -export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup; - -/** One selectable action row; a leading `Icon` sits in the gutter. */ -export const ContextMenuItem = menuPart( - ContextMenuPrimitive.Item, - "ui-overlay__item ui-menu__item", -); - -/** Non-interactive heading above a group. */ -export const ContextMenuLabel = menuPart( - ContextMenuPrimitive.Label, - "ui-menu__label", -); - -/** Rule between groups of items. */ -export const ContextMenuSeparator = menuPart( - ContextMenuPrimitive.Separator, - "ui-menu__separator", -); - -/** A toggleable row; checked shows a gutter check. */ -export const ContextMenuCheckboxItem = menuPart( - ContextMenuPrimitive.CheckboxItem, - "ui-overlay__item ui-menu__item", - { indicator: ContextMenuPrimitive.ItemIndicator }, -); - -/** One choice in a radio group. */ -export const ContextMenuRadioItem = menuPart( - ContextMenuPrimitive.RadioItem, - "ui-overlay__item ui-menu__item", - { indicator: ContextMenuPrimitive.ItemIndicator }, -); - -/** The row that opens its submenu. */ -export const ContextMenuSubTrigger = menuPart( - ContextMenuPrimitive.SubTrigger, - "ui-overlay__item ui-menu__item", - { chevron: true }, -); - -/** The floating menu surface, portalled to `body` at the pointer. */ -export function ContextMenuContent({ - className, - ...props -}: ComponentPropsWithRef< - typeof ContextMenuPrimitive.Content ->): React.JSX.Element { - return ( - - - - ); -} - -/** The floating submenu surface. */ -export function ContextMenuSubContent({ - className, - ...props -}: ComponentPropsWithRef< - typeof ContextMenuPrimitive.SubContent ->): React.JSX.Element { - return ( - - - - ); +export function ContextMenuContent(props: MenuContentProps): React.JSX.Element { + // Setting `side` also skips Base UI's context-menu offsets. + return ; } diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx index afaee078b7..c2bf43b760 100644 --- a/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx +++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx @@ -1,66 +1,24 @@ -import { expect, waitFor } from "storybook/test"; - -import { openMenu, openSubmenuByKeyboard, PIXEL_ALL_THEMES } from "#storybook"; +import { highlightRow, PIXEL_ALL_THEMES } from "#storybook"; import { Button } from "../Button/Button"; -import { Icon } from "../Icon/Icon"; +import { MenuItem } from "../Menu/Menu"; +import { MenuExampleItems } from "../Menu/MenuExampleItems"; import { DropdownMenu, - DropdownMenuCheckboxItem, DropdownMenuContent, - DropdownMenuItem, - DropdownMenuKeybinding, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, } from "./DropdownMenu"; import type { Meta, StoryObj } from "@storybook/react-vite"; const MenuExample = (): React.JSX.Element => ( - - - - + + Workspace actions} + /> - - - Start workspace - - - - Stop - - - Rebuild - - - - - Start on connect - - - Sort by - - Name - Status - - - - More actions - - Open logs - Edit settings - - + ); @@ -75,30 +33,21 @@ type Story = StoryObj; export const Open: Story = { parameters: { pixel: PIXEL_ALL_THEMES }, - play: async ({ canvasElement }) => { - await openMenu(canvasElement, "Workspace actions"); - await openSubmenuByKeyboard("Open logs"); - }, + play: () => highlightRow("Open logs"), }; /* Long menus cap to the viewport by default; the story lowers the cap. */ export const ManyItems: Story = { render: () => ( - - - - + + Workspace actions} + /> {Array.from({ length: 40 }, (_, i) => ( - Workspace {i + 1} + Workspace {i + 1} ))} ), - play: async ({ canvasElement }) => { - const menu = await openMenu(canvasElement, "Workspace actions"); - await waitFor(() => - expect(menu.scrollHeight).toBeGreaterThan(menu.clientHeight), - ); - }, }; diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx index 017264562c..95ae4539bb 100644 --- a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx +++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx @@ -1,104 +1,13 @@ -import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; +import { Menu as MenuPrimitive } from "@base-ui/react/menu"; -import { cx } from "#cx"; +import { MenuContent, type MenuContentProps } from "../Menu/Menu"; -import { menuPart } from "../Menu/Menu"; -import "../Menu/Menu.css"; -import "../overlay.css"; +export const DropdownMenu = MenuPrimitive.Root; -import type { ComponentPropsWithRef } from "react"; +export const DropdownMenuTrigger = MenuPrimitive.Trigger; -export { MenuKeybinding as DropdownMenuKeybinding } from "../Menu/Menu"; - -/** Root state container. */ -export const DropdownMenu = DropdownMenuPrimitive.Root; - -/** Opens the menu on click; renders its child element via `asChild`. */ -export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; - -/** Scopes one submenu. */ -export const DropdownMenuSub = DropdownMenuPrimitive.Sub; - -/** Groups radio items into one exclusive selection. */ -export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup; - -/** One selectable action row; a leading `Icon` sits in the gutter. */ -export const DropdownMenuItem = menuPart( - DropdownMenuPrimitive.Item, - "ui-overlay__item ui-menu__item", -); - -/** Non-interactive heading above a group. */ -export const DropdownMenuLabel = menuPart( - DropdownMenuPrimitive.Label, - "ui-menu__label", -); - -/** Rule between groups of items. */ -export const DropdownMenuSeparator = menuPart( - DropdownMenuPrimitive.Separator, - "ui-menu__separator", -); - -/** A toggleable row; checked shows a gutter check. */ -export const DropdownMenuCheckboxItem = menuPart( - DropdownMenuPrimitive.CheckboxItem, - "ui-overlay__item ui-menu__item", - { indicator: DropdownMenuPrimitive.ItemIndicator }, -); - -/** One choice in a radio group. */ -export const DropdownMenuRadioItem = menuPart( - DropdownMenuPrimitive.RadioItem, - "ui-overlay__item ui-menu__item", - { indicator: DropdownMenuPrimitive.ItemIndicator }, -); - -/** The row that opens its submenu. */ -export const DropdownMenuSubTrigger = menuPart( - DropdownMenuPrimitive.SubTrigger, - "ui-overlay__item ui-menu__item", - { chevron: true }, -); - -/** The floating menu surface, portalled to `body`. */ -export function DropdownMenuContent({ - className, - ...props -}: ComponentPropsWithRef< - typeof DropdownMenuPrimitive.Content ->): React.JSX.Element { - return ( - - - - ); -} - -/** The floating submenu surface. */ -export function DropdownMenuSubContent({ - className, - ...props -}: ComponentPropsWithRef< - typeof DropdownMenuPrimitive.SubContent ->): React.JSX.Element { - return ( - - - - ); +export function DropdownMenuContent( + props: MenuContentProps, +): React.JSX.Element { + return ; } diff --git a/packages/ui/src/components/ErrorState/ErrorState.stories.tsx b/packages/ui/src/components/ErrorState/ErrorState.stories.tsx index c4d1b1ec34..e0b44220ca 100644 --- a/packages/ui/src/components/ErrorState/ErrorState.stories.tsx +++ b/packages/ui/src/components/ErrorState/ErrorState.stories.tsx @@ -1,4 +1,4 @@ -import { expect, userEvent, within } from "storybook/test"; +import { userEvent, within } from "storybook/test"; import { PIXEL_ALL_THEMES } from "#storybook"; @@ -35,7 +35,6 @@ export const States: Story = { await userEvent.hover(retryButton); retryButton.focus(); - await expect(retryButton).toHaveFocus(); retryButton.blur(); await userEvent.unhover(retryButton); }, diff --git a/packages/ui/src/components/Icon/Icon.stories.tsx b/packages/ui/src/components/Icon/Icon.stories.tsx index ae4aa739ab..ea71686949 100644 --- a/packages/ui/src/components/Icon/Icon.stories.tsx +++ b/packages/ui/src/components/Icon/Icon.stories.tsx @@ -1,5 +1,3 @@ -import { expect, within } from "storybook/test"; - import { PIXEL_ALL_THEMES } from "#storybook"; import { Icon } from "./Icon"; @@ -40,11 +38,6 @@ type Story = StoryObj; export const States: Story = { parameters: { pixel: PIXEL_ALL_THEMES }, - play: async ({ canvasElement }) => { - await expect( - within(canvasElement).getByRole("img", { name: "Syncing" }), - ).toHaveClass("ui-icon--spin"); - }, }; export const Gallery: Story = { diff --git a/packages/ui/src/components/IconButton/IconButton.stories.tsx b/packages/ui/src/components/IconButton/IconButton.stories.tsx index 5bff37679e..710251293b 100644 --- a/packages/ui/src/components/IconButton/IconButton.stories.tsx +++ b/packages/ui/src/components/IconButton/IconButton.stories.tsx @@ -1,4 +1,4 @@ -import { expect, userEvent, within } from "storybook/test"; +import { userEvent, within } from "storybook/test"; import { PIXEL_ALL_THEMES } from "#storybook"; @@ -32,8 +32,6 @@ export const States: Story = { await userEvent.hover(refreshButton); refreshButton.focus(); - await expect(refreshButton).toHaveFocus(); - await expect(canvas.getByRole("button", { name: "Delete" })).toBeDisabled(); refreshButton.blur(); await userEvent.unhover(refreshButton); }, diff --git a/packages/ui/src/components/IconButton/IconButton.tsx b/packages/ui/src/components/IconButton/IconButton.tsx index 27e2f877ad..2ec58815d9 100644 --- a/packages/ui/src/components/IconButton/IconButton.tsx +++ b/packages/ui/src/components/IconButton/IconButton.tsx @@ -4,7 +4,7 @@ import { cx } from "#cx"; import "../control.css"; import { Icon } from "../Icon/Icon"; -import { Tooltip, TooltipScope } from "../Tooltip/Tooltip"; +import { Tooltip } from "../Tooltip/Tooltip"; import "./IconButton.css"; @@ -40,9 +40,5 @@ export function IconButton({ ); if (!tooltip) return button; - return ( - - {button} - - ); + return {button}; } diff --git a/packages/ui/src/components/Menu/Menu.css b/packages/ui/src/components/Menu/Menu.css index d0ca4e4a81..80b7877199 100644 --- a/packages/ui/src/components/Menu/Menu.css +++ b/packages/ui/src/components/Menu/Menu.css @@ -2,11 +2,6 @@ .ui-menu { --ui-menu-gutter: 2em; --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) - ); --ui-overlay-highlight-background: var(--ui-menu-highlight-background); --ui-overlay-highlight-foreground: var(--ui-menu-highlight-foreground); --ui-overlay-highlight-outline: var(--ui-menu-selection-border); @@ -73,21 +68,18 @@ background: var(--ui-menu-separator); } -/* Modern UI menu motion (contextview.ts). Radix supplies the anchor-corner - origin, and holds unmount until the closed-state animation ends. */ +/* Modern UI menu motion (contextview.ts). The positioner supplies the + anchor-corner origin, and holds unmount until the close animation ends. */ @media (prefers-reduced-motion: no-preference) { .ui-menu { - transform-origin: var( - --radix-dropdown-menu-content-transform-origin, - var(--radix-context-menu-content-transform-origin) - ); + transform-origin: var(--transform-origin); } - .ui-menu[data-state="open"] { + .ui-menu[data-open] { animation: ui-menu-open 250ms cubic-bezier(0.22, 1, 0.36, 1) backwards; } - .ui-menu[data-state="closed"] { + .ui-menu[data-closed] { animation: ui-menu-close 150ms cubic-bezier(0.22, 1, 0.36, 1) forwards; } } @@ -109,11 +101,11 @@ /* Stable-parity motion while the design settles; :where() keeps the rules at the modern block's specificity so source order alone decides. */ @media (prefers-reduced-motion: no-preference) { - :where(:root[data-ui-style="stable"]) .ui-menu:not([data-state="closed"]) { + :where(:root[data-ui-style="stable"]) .ui-menu:not([data-closed]) { animation: ui-menu-fade-in 83ms linear; } - :where(:root[data-ui-style="stable"]) .ui-menu[data-state="closed"] { + :where(:root[data-ui-style="stable"]) .ui-menu[data-closed] { animation: none; } } @@ -126,8 +118,8 @@ /* data-pixel is set in .storybook/preview.ts via isPixel(). */ @media (prefers-reduced-motion: no-preference) { - :where(:root[data-pixel]) .ui-menu[data-state="open"], - :where(:root[data-pixel]) .ui-menu[data-state="closed"] { + :where(:root[data-pixel]) .ui-menu[data-open], + :where(:root[data-pixel]) .ui-menu[data-closed] { animation: none; } } diff --git a/packages/ui/src/components/Menu/Menu.tsx b/packages/ui/src/components/Menu/Menu.tsx index c219a66998..ff0bd99ded 100644 --- a/packages/ui/src/components/Menu/Menu.tsx +++ b/packages/ui/src/components/Menu/Menu.tsx @@ -1,16 +1,18 @@ -import { cx } from "#cx"; +import { Menu as MenuPrimitive } from "@base-ui/react/menu"; + +import { cx, type Styled } from "#cx"; import { formatKeybinding, type Keybinding } from "../../keybinding"; import { Icon } from "../Icon/Icon"; +import "../overlay.css"; import "./Menu.css"; -import type { ComponentPropsWithRef, ElementType, ReactNode } from "react"; +import type { ComponentPropsWithRef } from "react"; + +const ITEM_CLASS = "ui-overlay__item ui-menu__item"; -/** - * Keybinding hint inside a menu item; `keys` takes a keybindings - * contribution's fields and renders the current OS's native label style. - */ +/** Shortcut hint in the current OS's native label style. */ export function MenuKeybinding({ keys, className, @@ -25,47 +27,137 @@ export function MenuKeybinding({ ); } -interface MenuPartOptions { - /** The primitive's `ItemIndicator`, which checks the item in the gutter. */ - indicator?: ElementType; - /** Appends the trailing submenu chevron. */ - chevron?: boolean; +export const MenuSub = MenuPrimitive.SubmenuRoot; + +export const MenuGroup = MenuPrimitive.Group; + +export const MenuRadioGroup = MenuPrimitive.RadioGroup; + +/** A leading `Icon` sits in the gutter. */ +export function MenuItem({ + className, + ...props +}: Styled< + ComponentPropsWithRef +>): React.JSX.Element { + return ( + + ); +} + +/** Names the `MenuGroup` or `MenuRadioGroup` it sits in. */ +export function MenuLabel({ + className, + ...props +}: Styled< + ComponentPropsWithRef +>): React.JSX.Element { + return ( + + ); +} + +export function MenuSeparator({ + className, + ...props +}: Styled< + ComponentPropsWithRef +>): React.JSX.Element { + return ( + + ); +} + +export function MenuCheckboxItem({ + className, + children, + ...props +}: Styled< + ComponentPropsWithRef +>): React.JSX.Element { + return ( + + } /> + {children} + + ); +} + +export function MenuRadioItem({ + className, + children, + ...props +}: Styled< + ComponentPropsWithRef +>): React.JSX.Element { + return ( + + } /> + {children} + + ); +} + +export function MenuSubTrigger({ + className, + children, + ...props +}: Styled< + ComponentPropsWithRef +>): React.JSX.Element { + return ( + + {children} + + + ); } -/** - * Applies a menu class to a Radix part. ContextMenu and DropdownMenu each own - * a separate Radix scope, so both build their parts from their own primitives. - */ -export function menuPart( - Part: T, - base: string, - { indicator: Indicator, chevron }: MenuPartOptions = {}, -): (props: ComponentPropsWithRef) => React.JSX.Element { - const Component: ElementType = Part; - - function MenuPart({ - className, - children, - ...props - }: { - className?: string; - children?: ReactNode; - }): React.JSX.Element { - return ( - - {Indicator ? ( - - - - ) : null} - {children} - {chevron ? ( - - ) : null} - - ); - } - MenuPart.displayName = base; - - return MenuPart; +export type MenuContentProps = Styled< + ComponentPropsWithRef +> & + Pick< + ComponentPropsWithRef, + "side" | "align" | "sideOffset" | "alignOffset" + >; + +/** Also a submenu's surface. Placement props go to the positioner, the rest to the menu. */ +export function MenuContent({ + side, + align, + sideOffset = 2, + alignOffset, + className, + ...props +}: MenuContentProps): React.JSX.Element { + return ( + + + + + + ); } diff --git a/packages/ui/src/components/Menu/MenuExampleItems.tsx b/packages/ui/src/components/Menu/MenuExampleItems.tsx new file mode 100644 index 0000000000..4f4651931c --- /dev/null +++ b/packages/ui/src/components/Menu/MenuExampleItems.tsx @@ -0,0 +1,47 @@ +import { Icon } from "../Icon/Icon"; + +import { + MenuCheckboxItem, + MenuContent, + MenuItem, + MenuKeybinding, + MenuLabel, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuSub, + MenuSubTrigger, +} from "./Menu"; + +export const MenuExampleItems = (): React.JSX.Element => ( + <> + + + Start workspace + + + + Stop + + + Rebuild + + + + Start on connect + + + Sort by + Name + Status + + + + More actions + + Open logs + Edit settings + + + +); diff --git a/packages/ui/src/components/ProgressBar/ProgressBar.stories.tsx b/packages/ui/src/components/ProgressBar/ProgressBar.stories.tsx index 3bb2f460c3..48091feae1 100644 --- a/packages/ui/src/components/ProgressBar/ProgressBar.stories.tsx +++ b/packages/ui/src/components/ProgressBar/ProgressBar.stories.tsx @@ -1,5 +1,3 @@ -import { expect, within } from "storybook/test"; - import { PIXEL_ALL_THEMES } from "#storybook"; import { ProgressBar } from "./ProgressBar"; @@ -23,12 +21,4 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const States: Story = { - play: async ({ canvasElement }) => { - const progress = within(canvasElement).getByRole("progressbar", { - name: "Custom range", - }); - await expect(progress).toHaveAttribute("aria-valuemax", "5"); - await expect(progress).toHaveAttribute("aria-valuenow", "3"); - }, -}; +export const States: Story = {}; diff --git a/packages/ui/src/components/SearchInput/SearchInput.stories.tsx b/packages/ui/src/components/SearchInput/SearchInput.stories.tsx index 46b59bccd0..21110a56d2 100644 --- a/packages/ui/src/components/SearchInput/SearchInput.stories.tsx +++ b/packages/ui/src/components/SearchInput/SearchInput.stories.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { expect, userEvent, within } from "storybook/test"; +import { userEvent, within } from "storybook/test"; import { PIXEL_ALL_THEMES } from "#storybook"; @@ -44,8 +44,6 @@ export const States: Story = { }); await userEvent.click(canvas.getByRole("button", { name: "Clear search" })); - await expect(searchbox).toHaveValue(""); - await expect(searchbox).toHaveFocus(); searchbox.blur(); }, }; diff --git a/packages/ui/src/components/Select/Select.css b/packages/ui/src/components/Select/Select.css index c3f057794f..678b022bcf 100644 --- a/packages/ui/src/components/Select/Select.css +++ b/packages/ui/src/components/Select/Select.css @@ -29,24 +29,23 @@ .ui-select__list { --ui-overlay-border: var(--ui-dropdown-border); - --ui-overlay-available-height: var(--radix-select-content-available-height); --ui-overlay-highlight-background: var(--ui-dropdown-list-focus-background); --ui-overlay-highlight-foreground: var(--ui-dropdown-list-focus-foreground); --ui-overlay-highlight-outline: var(--ui-list-selection-outline); - min-width: var(--radix-select-trigger-width); + display: flex; + flex-direction: column; + min-width: var(--anchor-width); padding: 2px; color: var(--ui-dropdown-foreground); background: var(--ui-dropdown-list-background); + overflow: hidden; } -/* Radix hides viewport scrollbars for its optional scroll buttons. */ -.ui-select__list [data-radix-select-viewport] { - scrollbar-width: auto; -} - -.ui-select__list [data-radix-select-viewport]::-webkit-scrollbar { - display: block; +/* The panel holds the padding and background; the rows scroll inside it. */ +.ui-select__viewport { + flex: 1; + overflow-y: auto; } .ui-select__item { diff --git a/packages/ui/src/components/Select/Select.stories.tsx b/packages/ui/src/components/Select/Select.stories.tsx index 825f99d34d..c715fa4fb3 100644 --- a/packages/ui/src/components/Select/Select.stories.tsx +++ b/packages/ui/src/components/Select/Select.stories.tsx @@ -13,39 +13,86 @@ import { import type { Meta, StoryObj } from "@storybook/react-vite"; +interface Region { + value: string; + label: string; + description?: string; + disabled?: boolean; +} + +const REGIONS: readonly Region[] = [ + { + value: "us-pittsburgh", + label: "US East (Pittsburgh)", + description: "Lowest latency", + }, + { value: "eu-helsinki", label: "EU North (Helsinki)" }, + { value: "ap-sydney", label: "Asia Pacific (Sydney)", disabled: true }, +]; + +const ONE: readonly Region[] = [{ value: "one", label: "One" }]; + +const POOL_COUNT = 20; +const LAST_POOL = String(POOL_COUNT - 1); + +const POOLS: readonly Region[] = Array.from( + { length: POOL_COUNT }, + (_, index) => ({ + value: String(index), + label: + index === POOL_COUNT - 1 + ? "US East (Pittsburgh), dedicated high-memory workspace pool" + : `Pool ${index}`, + disabled: index === 1, + }), +); + +const Options = ({ + items, + style, +}: { + items: readonly Region[]; + style?: React.CSSProperties; +}): React.JSX.Element => ( + + {items.map(({ value, label, description, disabled }) => ( + + {label} + + ))} + +); + const RegionSelect = (): React.JSX.Element => { const [region, setRegion] = useState("us-pittsburgh"); return (
- setRegion(value ?? "")} + > - - - US East (Pittsburgh) - - EU North (Helsinki) - - Asia Pacific (Sydney) - - + - undefined}> - - One - + - undefined} disabled> - - One - +
); @@ -78,32 +125,19 @@ export const Open: Story = { export const LongList: Story = { render: () => (
- - - {Array.from({ length: 20 }, (_, index) => ( - - {index === 0 - ? "US East (Pittsburgh), dedicated high-memory workspace pool" - : `Pool ${index}`} - - ))} - +
), - // Scroll to the end of the list so the snapshot shows a scrolled viewport. + // The selected row is last, so opening scrolls the viewport to the end. play: async ({ canvasElement }) => { await userEvent.click( within(canvasElement).getByRole("combobox", { name: "Workspace pool" }), ); await screen.findByRole("listbox"); - await userEvent.keyboard("{End}"); }, }; diff --git a/packages/ui/src/components/Select/Select.tsx b/packages/ui/src/components/Select/Select.tsx index dfa0bdb987..c17b28640b 100644 --- a/packages/ui/src/components/Select/Select.tsx +++ b/packages/ui/src/components/Select/Select.tsx @@ -1,7 +1,7 @@ -import * as SelectPrimitive from "@radix-ui/react-select"; +import { Select as SelectPrimitive } from "@base-ui/react/select"; import { useId, type ComponentPropsWithRef, type ReactNode } from "react"; -import { cx } from "#cx"; +import { cx, type Styled } from "#cx"; import "../control.css"; import { Icon } from "../Icon/Icon"; @@ -9,60 +9,76 @@ import "../overlay.css"; import "./Select.css"; -export const Select = SelectPrimitive.Root; +export type SelectProps = Omit< + SelectPrimitive.Root.Props, + "items" +> & { + /** The labels the trigger shows. */ + items: + | Readonly> + | ReadonlyArray<{ readonly value: Value; readonly label: ReactNode }>; +}; + +export function Select(props: SelectProps): React.JSX.Element { + return ; +} -/** Renders the selected item's text, or `placeholder` when empty. */ export const SelectValue = SelectPrimitive.Value; -/** The closed control: current value and a chevron, styled like the - native dropdown. */ export function SelectTrigger({ className, children, ...props -}: ComponentPropsWithRef): React.JSX.Element { +}: Styled< + ComponentPropsWithRef +>): React.JSX.Element { return ( {children} - - - + ); } -/** The floating option list, portalled to `body` and sized to the trigger. - Like the native select dropdown it appears without animation. */ +/** `className` and `style` dress the panel; other props go to the listbox. */ export function SelectContent({ className, - children, + style, ...props -}: ComponentPropsWithRef): React.JSX.Element { +}: Styled< + ComponentPropsWithRef +>): React.JSX.Element { return ( - - {children} - + + + + ); } -export interface SelectItemProps extends ComponentPropsWithRef< - typeof SelectPrimitive.Item -> { +export type SelectItemProps = Styled< + ComponentPropsWithRef +> & { description?: ReactNode; -} +}; -/** One option row; the highlighted row marks selection, like the native - list. An optional description renders as a muted second line. */ +/** `description` renders as a muted second line. */ export function SelectItem({ className, children, @@ -71,15 +87,20 @@ export function SelectItem({ ...props }: SelectItemProps): React.JSX.Element { const descriptionId = useId(); + const labelId = useId(); return ( - {children} + + {children} + {description !== undefined && ( {description} diff --git a/packages/ui/src/components/Spinner/Spinner.stories.tsx b/packages/ui/src/components/Spinner/Spinner.stories.tsx index a75c0b08f4..f92dee0307 100644 --- a/packages/ui/src/components/Spinner/Spinner.stories.tsx +++ b/packages/ui/src/components/Spinner/Spinner.stories.tsx @@ -1,5 +1,3 @@ -import { expect, within } from "storybook/test"; - import { PIXEL_ALL_THEMES } from "#storybook"; import { Spinner } from "./Spinner"; @@ -22,8 +20,4 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const Sizes: Story = { - play: async ({ canvasElement }) => { - await expect(within(canvasElement).getAllByRole("status")).toHaveLength(3); - }, -}; +export const Sizes: Story = {}; diff --git a/packages/ui/src/components/Tooltip/Tooltip.css b/packages/ui/src/components/Tooltip/Tooltip.css index 3c6b5b7a74..69806dcc6b 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.css +++ b/packages/ui/src/components/Tooltip/Tooltip.css @@ -2,12 +2,8 @@ .ui-tooltip { --ui-overlay-border: var(--ui-tooltip-border); /* Native hovers stop growing at half the window height */ - --ui-overlay-available-height: min( - 50vh, - var(--radix-tooltip-content-available-height) - ); - - max-width: min(700px, var(--radix-tooltip-content-available-width)); + max-height: min(50vh, var(--available-height)); + max-width: min(700px, var(--available-width)); padding: 4px 8px; color: var(--ui-tooltip-foreground); background: var(--ui-tooltip-background); diff --git a/packages/ui/src/components/Tooltip/Tooltip.stories.tsx b/packages/ui/src/components/Tooltip/Tooltip.stories.tsx index a1ec49a45d..87273c57b7 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.stories.tsx +++ b/packages/ui/src/components/Tooltip/Tooltip.stories.tsx @@ -1,10 +1,10 @@ -import { screen, userEvent } from "storybook/test"; +import { within } from "storybook/test"; import { PIXEL_ALL_THEMES } from "#storybook"; import { Button } from "../Button/Button"; -import { Tooltip, TooltipProvider } from "./Tooltip"; +import { Tooltip } from "./Tooltip"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -16,15 +16,8 @@ const LONG_TEXT = const meta: Meta = { title: "UI/Tooltip", component: Tooltip, - decorators: [ - // Instant in stories; the provider default is 500ms - (Story) => ( - - - - ), - ], args: { + open: true, content: "Stops the workspace", children: , }, @@ -33,15 +26,14 @@ const meta: Meta = { export default meta; type Story = StoryObj; -/* Focusing the trigger skips the pointer show delay. */ -async function openTooltipWithKeyboard(): Promise { - await userEvent.tab(); - await screen.findByRole("tooltip"); -} +/* The trigger keeps its focus ring, the way a hover reached by keyboard looks. */ +const focusTrigger: Story["play"] = ({ canvasElement }) => { + within(canvasElement).getByRole("button", { name: "Stop" }).focus(); +}; export const Open: Story = { parameters: { pixel: PIXEL_ALL_THEMES }, - play: openTooltipWithKeyboard, + play: focusTrigger, }; /* Shows the wrap at the 700px width cap; maxHeight stands in for the real @@ -51,5 +43,5 @@ export const Overflow: Story = { content: Array.from({ length: 8 }, () => LONG_TEXT).join(" "), style: { maxHeight: 160 }, }, - play: openTooltipWithKeyboard, + play: focusTrigger, }; diff --git a/packages/ui/src/components/Tooltip/Tooltip.tsx b/packages/ui/src/components/Tooltip/Tooltip.tsx index 41bd97b748..67c509cce8 100644 --- a/packages/ui/src/components/Tooltip/Tooltip.tsx +++ b/packages/ui/src/components/Tooltip/Tooltip.tsx @@ -1,15 +1,18 @@ -import { Slot } from "@radix-ui/react-slot"; -import * as TooltipPrimitive from "@radix-ui/react-tooltip"; +import { Tooltip as TooltipPrimitive } from "@base-ui/react/tooltip"; +import { useRender } from "@base-ui/react/use-render"; import { createContext, use, + useId, + useState, type ComponentProps, type ComponentPropsWithRef, type PointerEvent, + type ReactElement, type ReactNode, } from "react"; -import { cx } from "#cx"; +import { cx, type Styled } from "#cx"; import "../overlay.css"; @@ -22,36 +25,22 @@ export type TooltipProviderProps = ComponentProps< /** VS Code's `workbench.hover.delay`. */ const DEFAULT_DELAY_MS = 500; -/** - * App-level tooltip context; `Tooltip` throws without one. Sharing a single - * provider lets a pointer moving between nearby triggers skip the show delay, - * like native hovers. - */ +/** Mount one per app so moving between nearby triggers skips the delay. */ export function TooltipProvider({ - delayDuration = DEFAULT_DELAY_MS, + delay = DEFAULT_DELAY_MS, ...props }: TooltipProviderProps): React.JSX.Element { return ( - - + + ); } -const TooltipContext = createContext(null); +const TooltipContext = createContext(DEFAULT_DELAY_MS); -/** Owns tooltips without forcing a provider on consumers; defers to any app-level one. */ -export function TooltipScope({ children }: { children: ReactNode }): ReactNode { - return use(TooltipContext) === null ? ( - {children} - ) : ( - children - ); -} - -/** The surrounding provider's show delay, for surfaces that time their own. */ export function useTooltipDelay(): number { - return use(TooltipContext) ?? DEFAULT_DELAY_MS; + return use(TooltipContext); } export interface HoverTarget { @@ -65,76 +54,145 @@ export type HoverDelegate = ( immediate?: boolean, ) => void; -const HoverDelegateContext = createContext( +/** Hands every `Tooltip` inside to one shared bubble. */ +export const HoverDelegateContext = createContext( undefined, ); -/** - * Hands every `Tooltip` inside to one shared bubble, the way a VS Code list - * serves its rows and their action bars from a single hover widget. Pass - * `undefined` to hand them back. - */ -export function HoverDelegateScope({ - delegate, - children, -}: { - delegate: HoverDelegate | undefined; - children: ReactNode; -}): React.JSX.Element { - return ( - {children} - ); -} +type PositionerProps = ComponentPropsWithRef< + typeof TooltipPrimitive.Positioner +>; export interface TooltipProps extends Omit< - ComponentPropsWithRef, - "content" + Styled>, + "content" | "children" > { content: ReactNode; - /** The trigger element; must accept a forwarded ref (asChild). */ - children: ReactNode; + /** The trigger element; must accept a forwarded ref. */ + children: ReactElement; open?: boolean; onOpenChange?: (open: boolean) => void; } -/** Hover bubble matching the native hover widget; requires a `TooltipProvider` ancestor. */ +function DelegatedTooltip({ + content, + children, + delegate, +}: { + content: ReactNode; + children: ReactElement; + delegate: HoverDelegate; +}): ReactNode { + return useRender({ + render: children, + props: { + onPointerEnter: (event: PointerEvent) => + delegate({ content, element: event.currentTarget }), + onPointerLeave: () => delegate(undefined), + }, + }); +} + export function Tooltip({ content, children, - className, - open, - onOpenChange, ...props -}: TooltipProps): React.JSX.Element { +}: TooltipProps): ReactNode { const delegate = use(HoverDelegateContext); if (delegate) { return ( - ) => - delegate({ content, element: event.currentTarget }) - } - onPointerLeave={() => delegate(undefined)} - > + {children} - + ); } return ( - - {children} - - + {children} + + ); +} + +function HoverBubble({ + content, + children, + open, + onOpenChange, + ...props +}: TooltipProps): React.JSX.Element { + const contentId = useId(); + const delay = useTooltipDelay(); + const [openedItself, setOpenedItself] = useState(false); + return ( + { + setOpenedItself(next); + onOpenChange?.(next); + }} + > + + + + ); +} + +export type HoverAnchor = NonNullable; + +type HoverPopupProps = Omit< + TooltipProps, + "children" | "open" | "onOpenChange" +> & { + anchor?: HoverAnchor; + align?: PositionerProps["align"]; +}; + +/** An open bubble at `anchor`, for callers that time their own hovers. */ +export function AnchoredHover({ + onOpenChange, + ...props +}: HoverPopupProps & + Pick & { + anchor: HoverAnchor; + }): React.JSX.Element { + return ( + + + + ); +} + +function HoverPopup({ + content, + className, + align = "center", + anchor, + ...props +}: HoverPopupProps): React.JSX.Element { + return ( + + + {content} - - - + + + ); } diff --git a/packages/ui/src/components/Tree/Tree.css b/packages/ui/src/components/Tree/Tree.css index ebce965035..565068923c 100644 --- a/packages/ui/src/components/Tree/Tree.css +++ b/packages/ui/src/components/Tree/Tree.css @@ -213,10 +213,3 @@ border-color: CanvasText; } } - -/* The shared hover anchors here instead of to each row's label. */ -.ui-tree-hover-anchor { - position: absolute; - pointer-events: none; - opacity: 0; -} diff --git a/packages/ui/src/components/Tree/Tree.stories.tsx b/packages/ui/src/components/Tree/Tree.stories.tsx index b88d203654..1933042671 100644 --- a/packages/ui/src/components/Tree/Tree.stories.tsx +++ b/packages/ui/src/components/Tree/Tree.stories.tsx @@ -81,19 +81,13 @@ const exerciseTree: NonNullable = async ({ canvasElement }) => { // One pointer for the whole play, so the unhover at the end really lands. const user = userEvent.setup(); const canvas = within(canvasElement); - const selected = canvas.getByRole("treeitem", { name: "components" }); const treeItem = canvas.getByRole("treeitem", { name: "Tree.tsx" }); - await expect(selected).toHaveAttribute("aria-selected", "true"); await user.click( canvas.getByRole("button", { name: "Close Tree.tsx", hidden: true }), ); - await expect(selected).toHaveAttribute("aria-selected", "true"); - await expect(treeItem).toHaveAttribute("aria-selected", "false"); await user.click(treeItem); - await expect(treeItem).toHaveAttribute("aria-selected", "true"); const readme = canvas.getByRole("treeitem", { name: "README.md" }); await user.click(readme); - await expect(readme).toHaveAttribute("aria-selected", "true"); // Clicking leaves the pointer on the row; these stories snapshot hoverless. await user.unhover(readme); }; @@ -176,9 +170,6 @@ export const StickyScroll: Story = { canvasElement.querySelector(".ui-tree-sticky__rows"), ).not.toBeNull(), ); - await expect( - within(canvasElement).getByTestId("scroller").scrollTop, - ).toBeGreaterThan(0); }, }; @@ -193,18 +184,9 @@ export const MultiSelect: Story = { }), play: async ({ canvasElement }) => { const canvas = within(canvasElement); - const treeElement = canvas.getByRole("tree"); - const readme = canvas.getByRole("treeitem", { name: "README.md" }); - await fireEvent.click(readme, { ctrlKey: true }); - await expect(readme).toHaveAttribute("aria-selected", "true"); - await expect( - canvas.getByRole("treeitem", { name: "Tree.tsx" }), - ).toHaveAttribute("aria-selected", "true"); - await expect(canvasElement.ownerDocument.activeElement).toBe(treeElement); - await expect(treeElement).toHaveAttribute( - "aria-activedescendant", - readme.id, - ); + await fireEvent.click(canvas.getByRole("treeitem", { name: "README.md" }), { + ctrlKey: true, + }); }, }; @@ -217,18 +199,10 @@ export const Focused: Story = { variant: "explorer", }), play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const treeElement = canvas.getByRole("tree"); - const styles = canvas.getByRole("treeitem", { name: "Tree.css" }); + const treeElement = within(canvasElement).getByRole("tree"); treeElement.focus(); await waitFor(() => expect(treeElement).toHaveClass("ui-tree--focused")); await fireEvent.keyDown(treeElement, { key: "ArrowDown" }); - await expect(canvasElement.ownerDocument.activeElement).toBe(treeElement); - await expect(treeElement).toHaveAttribute( - "aria-activedescendant", - styles.id, - ); - await expect(styles).toHaveAttribute("aria-selected", "false"); }, }; @@ -261,12 +235,11 @@ export const Nested: Story = { pseudo: { hover: [".ui-tree", ".story-hover > .ui-tree-item__row"] }, }, play: async ({ canvasElement }) => { - const deepLeaf = within(canvasElement).getByRole("treeitem", { - name: "StickyScroll.tsx", - }); - await expect(deepLeaf).toHaveAttribute("aria-level", "5"); - await userEvent.click(deepLeaf); - await expect(deepLeaf).toHaveAttribute("aria-selected", "true"); + await userEvent.click( + within(canvasElement).getByRole("treeitem", { + name: "StickyScroll.tsx", + }), + ); }, }; @@ -317,8 +290,8 @@ const hoverAt = async (element: Element): Promise => { }); }; -/** The bubble is portalled, so it lands outside the story canvas. */ -const expectBubble = async (content: string): Promise => { +/** Waits for the portalled bubble to carry `content`, which a snapshot needs. */ +const showBubble = async (content: string): Promise => { const bubble = await screen.findByRole("tooltip"); await waitFor(() => expect(bubble).toHaveTextContent(content)); }; @@ -330,7 +303,7 @@ export const Hover: Story = { name: LONG_NAME, }); await hoverAt(hovered.getElementsByClassName("ui-tree-item__content")[0]); - await expectBubble(LONG_NAME); + await showBubble(LONG_NAME); }, }; @@ -342,12 +315,12 @@ export const HoverOnAction: Story = { await hoverAt( canvas.getByRole("button", { name: "Start workspace", hidden: true }), ); - await expectBubble("Start workspace"); + await showBubble("Start workspace"); // Crossing the same action bar swaps the bubble without a second delay. await hoverAt( canvas.getByRole("button", { name: "Workspace settings", hidden: true }), ); - await expectBubble("Workspace settings"); + await showBubble("Workspace settings"); }, }; @@ -362,6 +335,6 @@ export const HoverByKeyboard: Story = { // VS Code binds list.showHover to this chord. await fireEvent.keyDown(treeElement, { key: "k", ctrlKey: true }); await fireEvent.keyDown(treeElement, { key: "i", ctrlKey: true }); - await expectBubble(LONG_NAME); + await showBubble(LONG_NAME); }, }; diff --git a/packages/ui/src/components/Tree/Tree.tsx b/packages/ui/src/components/Tree/Tree.tsx index eef0bc6cbe..e5939edde6 100644 --- a/packages/ui/src/components/Tree/Tree.tsx +++ b/packages/ui/src/components/Tree/Tree.tsx @@ -3,8 +3,6 @@ import { type ComponentPropsWithRef, useId, useRef } from "react"; import { cx } from "#cx"; import { setForwardedRef } from "#ref"; -import { TooltipScope } from "../Tooltip/Tooltip"; - import { StickyScroll } from "./sticky/StickyScroll"; import "./Tree.css"; import { TreeHover, type TreeHoverControl } from "./TreeHover"; @@ -83,68 +81,66 @@ export function Tree({ }); return ( - -
{ - treeRef.current = element; - setForwardedRef(ref, element); - }} - role="tree" - tabIndex={0} - aria-activedescendant={ - adapter.focusedId ? `${treeDomId}-${adapter.focusedId}` : undefined +
{ + treeRef.current = element; + setForwardedRef(ref, element); + }} + role="tree" + tabIndex={0} + aria-activedescendant={ + adapter.focusedId ? `${treeDomId}-${adapter.focusedId}` : undefined + } + aria-multiselectable={multiSelect ? true : undefined} + className={cx( + "ui-tree", + variant === "explorer" && "ui-tree--explorer", + adapter.hasDomFocus && "ui-tree--focused", + className, + )} + onFocus={(event) => { + onFocus?.(event); + if ( + !event.defaultPrevented && + ownsTarget(event.currentTarget, event.target) + ) { + adapter.onFocusIn(event.target); + } + }} + onBlur={(event) => { + onBlur?.(event); + if ( + !event.defaultPrevented && + !ownsTarget(event.currentTarget, event.relatedTarget) + ) { + adapter.onBlurOut(); } - aria-multiselectable={multiSelect ? true : undefined} - className={cx( - "ui-tree", - variant === "explorer" && "ui-tree--explorer", - adapter.hasDomFocus && "ui-tree--focused", - className, - )} - onFocus={(event) => { - onFocus?.(event); - if ( - !event.defaultPrevented && - ownsTarget(event.currentTarget, event.target) - ) { - adapter.onFocusIn(event.target); - } - }} - onBlur={(event) => { - onBlur?.(event); - if ( - !event.defaultPrevented && - !ownsTarget(event.currentTarget, event.relatedTarget) - ) { - adapter.onBlurOut(); - } - }} - onClick={adapter.onClick} - onKeyDown={adapter.onKeyDown} - > - - {stickyScroll ? ( - - ) : null} - {adapter.model.visibleRows.map((row) => ( - - ))} - -
- + }} + onClick={adapter.onClick} + onKeyDown={adapter.onKeyDown} + > + + {stickyScroll ? ( + + ) : null} + {adapter.model.visibleRows.map((row) => ( + + ))} + +
); } diff --git a/packages/ui/src/components/Tree/TreeHover.tsx b/packages/ui/src/components/Tree/TreeHover.tsx index 7e58e75ace..f91c6476e3 100644 --- a/packages/ui/src/components/Tree/TreeHover.tsx +++ b/packages/ui/src/components/Tree/TreeHover.tsx @@ -8,9 +8,10 @@ import { } from "react"; import { - HoverDelegateScope, - Tooltip, + AnchoredHover, + HoverDelegateContext, useTooltipDelay, + type HoverAnchor, type HoverDelegate, type HoverTarget, } from "../Tooltip/Tooltip"; @@ -30,18 +31,11 @@ const ROW = ".ui-tree-item"; export type TreeHoverControl = RefObject; interface Shown extends HoverTarget { - readonly top: number; - readonly left: number; - readonly width: number; - readonly height: number; + readonly anchor: HoverAnchor; readonly align: "center" | "start"; } -/** - * One hover for the whole tree, like the native list's shared widget: rows and - * anything inside them report the element under the pointer, and an invisible - * anchor moves to it. - */ +/** One bubble for the whole tree, so rows only attach pointer handlers. */ export function TreeHover({ children, treeRef, @@ -80,22 +74,33 @@ export function TreeHover({ return; } const cluster = target.element.closest(DENSE_CLUSTER); - const box = cluster - ? target.element - : (target.element.closest(ROW) ?? target.element); const bounds = tree.getBoundingClientRect(); - const rect = cluster ? targetRect : box.getBoundingClientRect(); + const rect = cluster + ? targetRect + : (target.element.closest(ROW) ?? target.element).getBoundingClientRect(); const cursorX = cluster || !atPointer ? undefined : pointerXRef.current; + const top = rect.top - bounds.top; + const left = + (cursorX === undefined ? rect.left : cursorX + CURSOR_OFFSET_PX) - + bounds.left; + const width = cursorX === undefined ? rect.width : 0; openRef.current = true; clusterRef.current = cluster; setShown({ ...target, - top: rect.top - bounds.top, - left: - (cursorX === undefined ? rect.left : cursorX + CURSOR_OFFSET_PX) - - bounds.left, - width: cursorX === undefined ? rect.width : 0, - height: rect.height, + // Relative to the tree, so the bubble follows it when it scrolls. + anchor: { + contextElement: tree, + getBoundingClientRect: () => { + const now = tree.getBoundingClientRect(); + return new DOMRect( + now.left + left, + now.top + top, + width, + rect.height, + ); + }, + }, align: cursorX === undefined ? "center" : "start", }); }; @@ -131,46 +136,29 @@ export function TreeHover({ pointerXRef.current = event.clientX; }; tree.addEventListener("pointermove", track, { passive: true }); - return () => tree.removeEventListener("pointermove", track); - }, [treeRef]); - - useEffect(() => { - const tree = treeRef.current; - if (!tree) return; // Capture also dismisses hovers when an action stops propagation. tree.addEventListener("pointerdown", hide, true); - return () => tree.removeEventListener("pointerdown", hide, true); + return () => { + tree.removeEventListener("pointermove", track); + tree.removeEventListener("pointerdown", hide, true); + }; }, [treeRef, hide]); return ( - + {children} - {/* Outside the scope, or the bubble would delegate to itself. */} - - {shown ? ( - { - if (!open) hide(); - }} - onPointerEnter={() => clearTimeout(timerRef.current)} - onPointerLeave={() => setTarget(undefined)} - > - - ) : null} - - + {shown ? ( + { + if (!open) hide(); + }} + onPointerEnter={() => clearTimeout(timerRef.current)} + onPointerLeave={() => setTarget(undefined)} + /> + ) : null} + ); } diff --git a/packages/ui/src/components/overlay.css b/packages/ui/src/components/overlay.css index 00026705b8..0ad6c0a891 100644 --- a/packages/ui/src/components/overlay.css +++ b/packages/ui/src/components/overlay.css @@ -1,9 +1,9 @@ -/* Shared base for portalled overlays; each surface maps its border color - and its Radix available-height variable onto the --ui-overlay-* names. */ +/* Shared base for portalled overlays; each surface maps its border color onto + the --ui-overlay-* names. */ .ui-overlay { box-sizing: border-box; - /* Cap to the space Radix reports so oversized content scrolls */ - max-height: var(--ui-overlay-available-height, none); + /* Cap to the space the positioner reports so oversized content scrolls */ + max-height: var(--available-height); overflow-y: auto; border: var(--ui-stroke-thickness) solid var(--ui-overlay-border, transparent); border-radius: var(--ui-radius-large); @@ -23,7 +23,7 @@ /* An open submenu keeps its parent row highlighted, like native; the selection border only resolves in high contrast. */ -.ui-overlay__item:is([data-highlighted], [data-state="open"]) { +.ui-overlay__item:is([data-highlighted], [data-popup-open]) { forced-color-adjust: none; color: var(--ui-overlay-highlight-foreground); background: var(--ui-overlay-highlight-background); @@ -39,7 +39,7 @@ border-color: CanvasText; } - .ui-overlay__item:is([data-highlighted], [data-state="open"]) { + .ui-overlay__item:is([data-highlighted], [data-popup-open]) { color: HighlightText; background: Highlight; } diff --git a/packages/ui/src/cx.ts b/packages/ui/src/cx.ts index e8e639d30f..4d6c0c0d4f 100644 --- a/packages/ui/src/cx.ts +++ b/packages/ui/src/cx.ts @@ -1,4 +1,12 @@ +import type { CSSProperties } from "react"; + /** Joins the truthy class names, dropping conditional falsy entries. */ export function cx(...classes: Array): string { return classes.filter(Boolean).join(" "); } + +/** Narrows a part's `className` and `style` to the plain values `cx` takes. */ +export type Styled

= Omit & { + className?: string; + style?: CSSProperties; +}; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d6278d896e..507bc8a49d 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -41,6 +41,7 @@ export { SelectContent, SelectItem, type SelectItemProps, + type SelectProps, SelectTrigger, SelectValue, } from "./components/Select/Select"; @@ -54,43 +55,34 @@ export { Textarea, type TextareaProps } from "./components/Textarea/Textarea"; export type { CodiconName } from "./codicons"; export { ContextMenu, - ContextMenuCheckboxItem, ContextMenuContent, - ContextMenuItem, - ContextMenuKeybinding, - ContextMenuLabel, - ContextMenuRadioGroup, - ContextMenuRadioItem, - ContextMenuSeparator, - ContextMenuSub, - ContextMenuSubContent, - ContextMenuSubTrigger, ContextMenuTrigger, } from "./components/ContextMenu/ContextMenu"; export { DropdownMenu, - DropdownMenuCheckboxItem, DropdownMenuContent, - DropdownMenuItem, - DropdownMenuKeybinding, - DropdownMenuLabel, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuSub, - DropdownMenuSubContent, - DropdownMenuSubTrigger, DropdownMenuTrigger, } from "./components/DropdownMenu/DropdownMenu"; +export { + MenuCheckboxItem, + MenuContent, + type MenuContentProps, + MenuGroup, + MenuItem, + MenuKeybinding, + MenuLabel, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuSub, + MenuSubTrigger, +} from "./components/Menu/Menu"; export { formatKeybinding, type Keybinding, type KeybindingPlatform, } from "./keybinding"; export { - type HoverDelegate, - HoverDelegateScope, - type HoverTarget, Tooltip, type TooltipProps, TooltipProvider, diff --git a/packages/ui/src/storybook.ts b/packages/ui/src/storybook.ts index e799530fea..4d8aa9c881 100644 --- a/packages/ui/src/storybook.ts +++ b/packages/ui/src/storybook.ts @@ -1,4 +1,4 @@ -import { expect, screen, userEvent, waitFor, within } from "storybook/test"; +import { screen } from "storybook/test"; /** * Pixel matrix override (`parameters.pixel`) that snapshots a story in every @@ -10,21 +10,7 @@ export const PIXEL_ALL_THEMES = { }, } as const; -/* Clicks a menu trigger and returns its portalled surface. */ -export async function openMenu( - canvasElement: HTMLElement, - triggerName: string, -): Promise { - await userEvent.click( - within(canvasElement).getByRole("button", { name: triggerName }), - ); - return screen.findByRole("menu"); -} - -/* Opens the focused menu's submenu; keyboard skips the hover-open delay. */ -export async function openSubmenuByKeyboard(itemName: string): Promise { - const menu = await screen.findByRole("menu"); - await waitFor(() => expect(menu.contains(document.activeElement)).toBe(true)); - await userEvent.keyboard("{End}{ArrowRight}"); - await screen.findByRole("menuitem", { name: itemName }); +/* Highlights a portalled menu row, the way arrowing onto it would. */ +export async function highlightRow(name: string): Promise { + (await screen.findByRole("menuitem", { name })).focus(); } diff --git a/packages/ui/src/vscode-parity.stories.tsx b/packages/ui/src/vscode-parity.stories.tsx index d34d7407c4..edc54abc22 100644 --- a/packages/ui/src/vscode-parity.stories.tsx +++ b/packages/ui/src/vscode-parity.stories.tsx @@ -19,13 +19,15 @@ import { Checkbox } from "./components/Checkbox/Checkbox"; import { DropdownMenu, DropdownMenuContent, - DropdownMenuItem, - DropdownMenuKeybinding, - DropdownMenuSeparator, DropdownMenuTrigger, } from "./components/DropdownMenu/DropdownMenu"; import { IconButton } from "./components/IconButton/IconButton"; import { Input } from "./components/Input/Input"; +import { + MenuItem, + MenuKeybinding, + MenuSeparator, +} from "./components/Menu/Menu"; import { ProgressBar } from "./components/ProgressBar/ProgressBar"; import { SearchInput } from "./components/SearchInput/SearchInput"; import { @@ -42,12 +44,18 @@ import { PIXEL_ALL_THEMES } from "./storybook"; import type { Meta, StoryObj } from "@storybook/react-vite"; +const REGIONS: Readonly> = { + "us-pittsburgh": "US East (Pittsburgh)", + "eu-helsinki": "EU North (Helsinki)", +}; + /** * Renders every `@repo/ui` control next to its `@vscode-elements` * counterpart under identical theme variables. Pixel snapshots this * in all four themes, so any drift from VS Code's appearance shows up as * a visual diff. */ + const Row = ({ label, ours, @@ -181,13 +189,20 @@ const Parity = (): React.JSX.Element => ( undefined}> + } @@ -265,29 +280,32 @@ const MenuParity = (): React.JSX.Element => ( }} > - - {/* A zero-height button still anchors the popper, so the menu opens - at the top of its grid column, level with the reference. */} - } /> + {children} + +); + +describe("portalled overlays", () => { + it.each([ + { + name: "DropdownMenu", + role: "menu", + className: ["ui-overlay", "ui-menu"], + ui: (ref) => openMenu(One, ref), + }, + { + name: "Select", + role: "listbox", + className: ["ui-overlay", "ui-select__list"], + ui: (ref) => ( + + ), + }, + { + name: "Tooltip", + role: "tooltip", + className: ["ui-overlay", "ui-tooltip"], + ui: (ref) => ( + + + + ), + }, + ])( + "$name forwards props to its $role, dresses its popup, and reports its space to the CSS", + async ({ role, className, ui }) => { + const ref = createRef(); + render(ui(ref)); + const opened = await screen.findByRole(role); + expect(ref.current).toBe(opened); + // A select's listbox is the scroller inside the dressed panel. + const popup = opened.closest(".ui-overlay"); + expect(popup).toHaveClass(...className); + expect(popup).toHaveAttribute("data-open"); + const positioner = popup?.parentElement?.getAttribute("style") ?? ""; + for (const name of POSITIONER_VARS) { + expect(positioner).toContain(name); + } + }, + ); + + it("names a group by its label and highlights an open submenu's row", async () => { + render( + openMenu( + <> + + Sort by + Name + + + More + + Deep + + + , + ), + ); + expect(await screen.findByRole("group", { name: "Sort by" })).toBeVisible(); + const trigger = screen.getByRole("menuitem", { name: "More" }); + await userEvent.click(trigger); + expect(await screen.findByRole("menuitem", { name: "Deep" })).toBeVisible(); + expect(trigger).toHaveAttribute("data-popup-open"); + }); +}); diff --git a/test/webview/ui/tree.hover.test.tsx b/test/webview/ui/tree.hover.test.tsx index 8916c400f8..da13d3fc86 100644 --- a/test/webview/ui/tree.hover.test.tsx +++ b/test/webview/ui/tree.hover.test.tsx @@ -36,7 +36,7 @@ function renderHoverTree() { />, { wrapper: ({ children }) => ( - {children} + {children} ), }, ); diff --git a/test/webview/ui/tree.keyboard.test.tsx b/test/webview/ui/tree.keyboard.test.tsx index 7ae897582f..738a16e9bb 100644 --- a/test/webview/ui/tree.keyboard.test.tsx +++ b/test/webview/ui/tree.keyboard.test.tsx @@ -183,7 +183,7 @@ describe("Tree keyboard navigation", () => { it("opens the focused row's hover on the show-hover chord", async () => { stubElementBoxes(); render( - + { it("moves one hover between labels, defaulting to the text value", async () => { stubElementBoxes(); render( - + { it("waits for a new target but crosses an action bar at once", async () => { stubElementBoxes(); render( - +