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( - +