Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions .storybook/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
50 changes: 25 additions & 25 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
6 changes: 1 addition & 5 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
55 changes: 7 additions & 48 deletions packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand All @@ -32,42 +22,11 @@ const TARGET_STYLE: React.CSSProperties = {

const MenuExample = (): React.JSX.Element => (
<ContextMenu>
<ContextMenuTrigger asChild>
<div style={TARGET_STYLE}>Right-click here</div>
</ContextMenuTrigger>
<ContextMenuTrigger
render={<div style={TARGET_STYLE}>Right-click here</div>}
/>
<ContextMenuContent>
<ContextMenuItem>
<Icon name="play" />
Start workspace
</ContextMenuItem>
<ContextMenuItem disabled>
<Icon name="stop-circle" />
Stop
</ContextMenuItem>
<ContextMenuItem>
Rebuild
<ContextMenuKeybinding
keys={{ key: "ctrl+shift+r", mac: "cmd+shift+r" }}
/>
</ContextMenuItem>
<ContextMenuSeparator />
<ContextMenuCheckboxItem checked>
Start on connect
</ContextMenuCheckboxItem>
<ContextMenuSeparator />
<ContextMenuLabel>Sort by</ContextMenuLabel>
<ContextMenuRadioGroup value="name">
<ContextMenuRadioItem value="name">Name</ContextMenuRadioItem>
<ContextMenuRadioItem value="status">Status</ContextMenuRadioItem>
</ContextMenuRadioGroup>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger>More actions</ContextMenuSubTrigger>
<ContextMenuSubContent>
<ContextMenuItem>Open logs</ContextMenuItem>
<ContextMenuItem>Edit settings</ContextMenuItem>
</ContextMenuSubContent>
</ContextMenuSub>
<MenuExampleItems />
</ContextMenuContent>
</ContextMenu>
);
Expand Down Expand Up @@ -95,6 +54,6 @@ export const Open: Story = {
clientY: rect.top + rect.height / 2,
},
});
await openSubmenuByKeyboard("Open logs");
await highlightRow("Open logs");
},
};
100 changes: 5 additions & 95 deletions packages/ui/src/components/ContextMenu/ContextMenu.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
// Native menus wrap focus when arrowing past the last item
loop
collisionPadding={4}
{...props}
className={cx("ui-overlay ui-menu", className)}
/>
</ContextMenuPrimitive.Portal>
);
}

/** The floating submenu surface. */
export function ContextMenuSubContent({
className,
...props
}: ComponentPropsWithRef<
typeof ContextMenuPrimitive.SubContent
>): React.JSX.Element {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.SubContent
sideOffset={2}
loop
collisionPadding={4}
{...props}
className={cx("ui-overlay ui-menu", className)}
/>
</ContextMenuPrimitive.Portal>
);
export function ContextMenuContent(props: MenuContentProps): React.JSX.Element {
// Setting `side` also skips Base UI's context-menu offsets.
return <MenuContent side="inline-end" align="start" {...props} />;
}
Loading
Loading