diff --git a/.storybook/global.css b/.storybook/global.css
index 77cea19e46..c8ce6db507 100644
--- a/.storybook/global.css
+++ b/.storybook/global.css
@@ -19,8 +19,7 @@ html body {
overflow: auto;
}
-/* Shrink the root from a full-width block to its content so Pixel's
- autofit crop stays tight. */
+/* Shrink-wrap the content so Pixel's autofit crop stays tight. */
#storybook-root {
width: fit-content;
}
diff --git a/.storybook/preview.ts b/.storybook/preview.ts
index 51d3c6c9f3..2634f46fad 100644
--- a/.storybook/preview.ts
+++ b/.storybook/preview.ts
@@ -44,8 +44,9 @@ if (
/**
* Applies a captured VS Code theme dump (`pnpm sync:vscode-themes`) as one
- * `:root` stylesheet and mirrors VS Code's body attribute for theme-aware
- * hooks. Synchronous and idempotent, so stories render fully themed.
+ * `:root` stylesheet and mirrors VS Code's body theme attributes for
+ * theme-aware hooks. Synchronous and idempotent, so stories render fully
+ * themed.
*/
let appliedTheme: string | undefined;
@@ -68,13 +69,14 @@ function applyTheme(requested: string): void {
.map(([property, value]) => `${property}: ${value};`)
.join("")}}`;
document.body.setAttribute("data-vscode-theme-kind", `vscode-${slug}`);
+ document.body.setAttribute("data-vscode-theme-id", slug);
}
/* Pixel's autofit crop follows in-flow layout, but portalled overlays
(menus, tooltips) are out of flow and would be cropped away. Grow the
- story root to cover any element portalled to body. Relies on the
- padded (top-left anchored) layout: growth only extends right and
- down, so already-positioned overlays never move. */
+ story root to cover them. Relies on the padded (top-left anchored)
+ layout: growth only extends right and down, so already-positioned
+ overlays never move. */
function fitRootToPortals(): void {
const root = document.getElementById("root");
if (!root) {
@@ -83,19 +85,10 @@ function fitRootToPortals(): void {
const origin = root.getBoundingClientRect();
let right = 0;
let bottom = 0;
- for (const el of document.body.children) {
- // Skip Storybook chrome (root, loaders, error display, a11y helpers)
- if (
- !(el instanceof HTMLElement) ||
- el.id.startsWith("storybook-") ||
- el.classList.contains("sb-wrapper")
- ) {
- continue;
- }
- const rect = el.getBoundingClientRect();
- if (rect.width === 0 || rect.height === 0) {
- continue;
- }
+ for (const overlay of document.querySelectorAll(
+ "[data-radix-popper-content-wrapper]",
+ )) {
+ const rect = overlay.getBoundingClientRect();
right = Math.max(right, rect.right - origin.left);
bottom = Math.max(bottom, rect.bottom - origin.top);
}
diff --git a/package.json b/package.json
index a2b0b42332..b1387e742d 100644
--- a/package.json
+++ b/package.json
@@ -843,7 +843,7 @@
"prettier": "^3.9.6",
"react": "catalog:",
"react-dom": "catalog:",
- "storybook": "^10.5.5",
+ "storybook": "catalog:",
"typescript": "catalog:",
"typescript-eslint": "^8.65.0",
"utf-8-validate": "^6.0.6",
diff --git a/packages/ui/README.md b/packages/ui/README.md
index bc548386f4..aace467562 100644
--- a/packages/ui/README.md
+++ b/packages/ui/README.md
@@ -19,6 +19,14 @@ import "@repo/ui/codicon.css";
`tokens.css` is the only layer that references VS Code's injected
`--vscode-*` variables. Components reference `--ui-*` tokens only.
+`--ui-background` is the sidebar surface, the common webview host; a webview
+hosted in an editor tab or bottom panel uses `--ui-panel-background` instead,
+since VS Code gives webviews no host signal to resolve it automatically.
+
+The radius and spacing tokens mirror VS Code's scale (`baseSizes.ts`), but
+only the rungs components actually use are declared; add one when a component
+needs it. Native menu, button, and hover paddings are hardcoded rather than
+scale-derived, so parity-pinned values stay literals.
Component CSS is inherit-first: typography and text color come from the
webview (`font: inherit`), and controls center content with a fixed height
@@ -35,9 +43,19 @@ after the library overrides any default (width, height, spacing).
`Tooltip`, `ContextMenu`, and `DropdownMenu` wrap the Radix primitives,
styled to match the native VS Code menu and hover widgets. Menus expose
Radix's compound parts as flat named exports (`DropdownMenuTrigger`,
-`DropdownMenuItem`, …); `Tooltip` is a single component taking a `content`
-prop, with a 500ms show delay matching VS Code's `workbench.hover.delay`
-default.
+`DropdownMenuItem`, `DropdownMenuCheckboxItem`, …): checkbox and radio
+items show a check in the icon gutter, `*Label` renders a group heading, and
+`*Keybinding` renders a shortcut hint. Pass `keys` the same `key`/`mac`/
+`win`/`linux` fields as a keybindings contribution to get the current OS's
+binding in its native label style (`⇧⌘R` on macOS, `Ctrl+Shift+R`
+elsewhere); `formatKeybinding` does the same for other surfaces, such as
+tooltips.
+
+`Tooltip` is a single component taking a `content` prop, and requires a
+`TooltipProvider` ancestor. Mount one provider per app so that a pointer
+moving between nearby triggers skips the show delay, like native hovers.
+The delay defaults to 500ms, matching VS Code's `workbench.hover.delay`,
+and tooltips stop growing at half the window height.
Overlay content is portalled to `body`, inherits webview typography from
there, and shares the `.ui-overlay` base for stacking, border, shadow,
@@ -47,41 +65,13 @@ an interrupted entry animation cannot delay unmounting. High contrast,
## Known gaps
-Deliberate deferrals, fine to fix later.
-
-Overlays:
-
-- Menus only support plain action items; Radix's checkbox/radio items,
- group labels, and keybinding hints have no styled wrappers yet.
-- Moving the pointer from one tooltip trigger straight to another replays
- the full 500ms delay, where native shows the next hover instantly. The
- fix is one shared `TooltipProvider` per app instead of one per
- `Tooltip`.
- Overlay shadows are darker than native in dark themes: menus in VS Code
use `shadow-lg`, which webviews cannot read, so the closest available
`widget.shadow` stands in.
-- A very tall tooltip fills most of the viewport before it scrolls, where
- native hovers stop at half the window height.
-
-Package-wide:
-
-- There is no `Button`; the VS Code button style exists only inside the
- state panels, and secondary-button colors have no `--ui-*` tokens.
-- Only the Empty and Error panels ship; a Loading panel would need the
- shared panel skeleton, which stays internal.
-- The token layer maps what shipped components need: there are no
- list/selection-row, spacing, typography, or z-index tokens, and the
- `--ui-radius-*` tokens are only adopted by the overlays, with older
- controls hardcoding their radii.
-- `--ui-background` assumes a sidebar webview; a webview hosted in an
- editor tab or panel renders on the sidebar color.
-- `useVscodeTheme` reports the theme kind only; switching between two
- themes of the same kind does not notify subscribers.
-- Under `prefers-reduced-motion` the indeterminate `ProgressBar` renders
- as a full bar and the `Spinner` as a static ring, with no other
- activity cue.
-- Story helpers compile against root-hoisted Storybook packages; a
- standalone split needs its own Storybook devDependencies.
+- Keybinding hints show the contributed defaults the consumer passes, not
+ user remaps: VS Code exposes no API for extensions to resolve a command's
+ effective keybinding.
+- List/selection-row tokens are deferred to the Tree suite (#1037).
## Codicons
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 8f64bb0e86..a2ea866658 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -19,7 +19,7 @@
"imports": {
"#cx": "./src/cx.ts",
"#codicons": "./src/codicons.ts",
- "#storybook": "./src/storybook.tsx"
+ "#storybook": "./src/storybook.ts"
},
"scripts": {
"typecheck": "tsc --noEmit"
@@ -35,10 +35,12 @@
"react-dom": "catalog:"
},
"devDependencies": {
+ "@storybook/react-vite": "catalog:",
"@types/react": "catalog:",
"@vscode-elements/react-elements": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
+ "storybook": "catalog:",
"typescript": "catalog:"
}
}
diff --git a/packages/ui/src/components/Button/Button.css b/packages/ui/src/components/Button/Button.css
new file mode 100644
index 0000000000..e6fc80ba21
--- /dev/null
+++ b/packages/ui/src/components/Button/Button.css
@@ -0,0 +1,30 @@
+/* monaco-text-button, flattened to a flex row: its 4px padding, 16px
+ leading, and 1px border make the 26px height. */
+.ui-button {
+ height: 26px;
+ padding: 0 8px;
+ color: var(--ui-button-foreground);
+ background: var(--ui-button-background);
+ border: 1px solid var(--ui-button-border);
+ border-radius: var(--ui-radius-small);
+ cursor: pointer;
+}
+
+.ui-button:hover:not(:disabled) {
+ background: var(--ui-button-hover-background);
+}
+
+.ui-button:focus {
+ outline: 1px solid var(--ui-focus-border);
+ outline-offset: 2px;
+}
+
+.ui-button--secondary {
+ color: var(--ui-button-secondary-foreground);
+ background: var(--ui-button-secondary-background);
+ border-color: var(--ui-button-secondary-border);
+}
+
+.ui-button--secondary:hover:not(:disabled) {
+ background: var(--ui-button-secondary-hover-background);
+}
diff --git a/packages/ui/src/components/Button/Button.stories.tsx b/packages/ui/src/components/Button/Button.stories.tsx
new file mode 100644
index 0000000000..b93692cd5c
--- /dev/null
+++ b/packages/ui/src/components/Button/Button.stories.tsx
@@ -0,0 +1,23 @@
+import { PIXEL_ALL_THEMES } from "#storybook";
+
+import { Button } from "./Button";
+
+import type { Meta, StoryObj } from "@storybook/react-vite";
+
+const ButtonStates = (): React.JSX.Element => (
+
+
+
+
+
+);
+
+const meta: Meta = {
+ title: "UI/Button",
+ component: ButtonStates,
+ parameters: { pixel: PIXEL_ALL_THEMES },
+};
+export default meta;
+type Story = StoryObj;
+
+export const States: Story = {};
diff --git a/packages/ui/src/components/Button/Button.tsx b/packages/ui/src/components/Button/Button.tsx
new file mode 100644
index 0000000000..a6a735c929
--- /dev/null
+++ b/packages/ui/src/components/Button/Button.tsx
@@ -0,0 +1,32 @@
+import { type ComponentProps } from "react";
+
+import { cx } from "#cx";
+
+import "../control.css";
+
+import "./Button.css";
+
+export interface ButtonProps extends ComponentProps<"button"> {
+ variant?: "primary" | "secondary";
+}
+
+/** Text button matching VS Code's monaco-text-button. */
+export function Button({
+ variant = "primary",
+ className,
+ type = "button",
+ ...props
+}: ButtonProps): React.JSX.Element {
+ return (
+
+ );
+}
diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx
index aa7814c504..c1eac7498a 100644
--- a/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx
+++ b/packages/ui/src/components/ContextMenu/ContextMenu.stories.tsx
@@ -6,8 +6,13 @@ import { Icon } from "../Icon/Icon";
import {
ContextMenu,
+ ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
+ ContextMenuKeybinding,
+ ContextMenuLabel,
+ ContextMenuRadioGroup,
+ ContextMenuRadioItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
@@ -39,6 +44,22 @@ const MenuExample = (): React.JSX.Element => (
Stop
+
+ Rebuild
+
+
+
+
+ Start on connect
+
+
+ Sort by
+
+ Name
+ Status
+ More actions
diff --git a/packages/ui/src/components/ContextMenu/ContextMenu.tsx b/packages/ui/src/components/ContextMenu/ContextMenu.tsx
index 3a6ea7c058..951d149f68 100644
--- a/packages/ui/src/components/ContextMenu/ContextMenu.tsx
+++ b/packages/ui/src/components/ContextMenu/ContextMenu.tsx
@@ -2,21 +2,65 @@ import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { cx } from "#cx";
-import { Icon } from "../Icon/Icon";
-import "../menu.css";
+import { menuPart } from "../Menu/Menu";
+import "../Menu/Menu.css";
import "../overlay.css";
import type { ComponentPropsWithRef } from "react";
-/** Root state container; wraps the trigger and content. */
+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; wraps its sub trigger and sub content. */
+/** Scopes one submenu. */
export const ContextMenuSub = ContextMenuPrimitive.Sub;
+/** Groups radio items into one exclusive selection. */
+export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
+
+/** One selectable action row; a leading `Icon` sits in the gutter. */
+export const ContextMenuItem = menuPart(
+ ContextMenuPrimitive.Item,
+ "ui-menu__item",
+);
+
+/** Non-interactive heading above a group. */
+export const ContextMenuLabel = menuPart(
+ ContextMenuPrimitive.Label,
+ "ui-menu__label",
+);
+
+/** Rule between groups of items. */
+export const ContextMenuSeparator = menuPart(
+ ContextMenuPrimitive.Separator,
+ "ui-menu__separator",
+);
+
+/** A toggleable row; checked shows a gutter check. */
+export const ContextMenuCheckboxItem = menuPart(
+ ContextMenuPrimitive.CheckboxItem,
+ "ui-menu__item",
+ { indicator: ContextMenuPrimitive.ItemIndicator },
+);
+
+/** One choice in a radio group. */
+export const ContextMenuRadioItem = menuPart(
+ ContextMenuPrimitive.RadioItem,
+ "ui-menu__item",
+ { indicator: ContextMenuPrimitive.ItemIndicator },
+);
+
+/** The row that opens its submenu. */
+export const ContextMenuSubTrigger = menuPart(
+ ContextMenuPrimitive.SubTrigger,
+ "ui-menu__item",
+ { chevron: true },
+);
+
/** The floating menu surface, portalled to `body` at the pointer. */
export function ContextMenuContent({
className,
@@ -37,7 +81,7 @@ export function ContextMenuContent({
);
}
-/** The floating submenu surface, opened by `ContextMenuSubTrigger`. */
+/** The floating submenu surface. */
export function ContextMenuSubContent({
className,
...props
@@ -56,50 +100,3 @@ export function ContextMenuSubContent({
);
}
-
-/** One selectable action row; a leading `Icon` sits in the gutter. */
-export function ContextMenuItem({
- className,
- ...props
-}: ComponentPropsWithRef): React.JSX.Element {
- return (
-
- );
-}
-
-/** The row that opens its submenu; renders a trailing chevron. */
-export function ContextMenuSubTrigger({
- className,
- children,
- ...props
-}: ComponentPropsWithRef<
- typeof ContextMenuPrimitive.SubTrigger
->): React.JSX.Element {
- return (
-
- {children}
-
-
- );
-}
-
-/** Thin rule between groups of items. */
-export function ContextMenuSeparator({
- className,
- ...props
-}: ComponentPropsWithRef<
- typeof ContextMenuPrimitive.Separator
->): React.JSX.Element {
- return (
-
- );
-}
diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx
index 2eef395388..afaee078b7 100644
--- a/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx
+++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.stories.tsx
@@ -1,17 +1,19 @@
-import { expect, screen, userEvent, waitFor, within } from "storybook/test";
+import { expect, waitFor } from "storybook/test";
-import {
- openSubmenuByKeyboard,
- PIXEL_ALL_THEMES,
- STORY_TRIGGER_CLASS,
-} from "#storybook";
+import { openMenu, openSubmenuByKeyboard, PIXEL_ALL_THEMES } from "#storybook";
+import { Button } from "../Button/Button";
import { Icon } from "../Icon/Icon";
import {
DropdownMenu,
+ DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuItem,
+ DropdownMenuKeybinding,
+ DropdownMenuLabel,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
@@ -24,9 +26,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
const MenuExample = (): React.JSX.Element => (
-
+
@@ -37,6 +37,22 @@ const MenuExample = (): React.JSX.Element => (
Stop
+
+ Rebuild
+
+
+
+
+ Start on connect
+
+
+ Sort by
+
+ Name
+ Status
+ More actions
@@ -60,9 +76,7 @@ type Story = StoryObj;
export const Open: Story = {
parameters: { pixel: PIXEL_ALL_THEMES },
play: async ({ canvasElement }) => {
- await userEvent.click(
- within(canvasElement).getByRole("button", { name: "Workspace actions" }),
- );
+ await openMenu(canvasElement, "Workspace actions");
await openSubmenuByKeyboard("Open logs");
},
};
@@ -72,9 +86,7 @@ export const ManyItems: Story = {
render: () => (
-
+
{Array.from({ length: 40 }, (_, i) => (
@@ -84,10 +96,7 @@ export const ManyItems: Story = {
),
play: async ({ canvasElement }) => {
- await userEvent.click(
- within(canvasElement).getByRole("button", { name: "Workspace actions" }),
- );
- const menu = await screen.findByRole("menu");
+ const menu = await openMenu(canvasElement, "Workspace actions");
await waitFor(() =>
expect(menu.scrollHeight).toBeGreaterThan(menu.clientHeight),
);
diff --git a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx
index b02daab587..92d3c4b093 100644
--- a/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx
+++ b/packages/ui/src/components/DropdownMenu/DropdownMenu.tsx
@@ -2,21 +2,65 @@ import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { cx } from "#cx";
-import { Icon } from "../Icon/Icon";
-import "../menu.css";
+import { menuPart } from "../Menu/Menu";
+import "../Menu/Menu.css";
import "../overlay.css";
import type { ComponentPropsWithRef } from "react";
-/** Root state container; wraps the trigger and content. */
+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; wraps its sub trigger and sub content. */
+/** Scopes one submenu. */
export const DropdownMenuSub = DropdownMenuPrimitive.Sub;
+/** Groups radio items into one exclusive selection. */
+export const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
+
+/** One selectable action row; a leading `Icon` sits in the gutter. */
+export const DropdownMenuItem = menuPart(
+ DropdownMenuPrimitive.Item,
+ "ui-menu__item",
+);
+
+/** Non-interactive heading above a group. */
+export const DropdownMenuLabel = menuPart(
+ DropdownMenuPrimitive.Label,
+ "ui-menu__label",
+);
+
+/** Rule between groups of items. */
+export const DropdownMenuSeparator = menuPart(
+ DropdownMenuPrimitive.Separator,
+ "ui-menu__separator",
+);
+
+/** A toggleable row; checked shows a gutter check. */
+export const DropdownMenuCheckboxItem = menuPart(
+ DropdownMenuPrimitive.CheckboxItem,
+ "ui-menu__item",
+ { indicator: DropdownMenuPrimitive.ItemIndicator },
+);
+
+/** One choice in a radio group. */
+export const DropdownMenuRadioItem = menuPart(
+ DropdownMenuPrimitive.RadioItem,
+ "ui-menu__item",
+ { indicator: DropdownMenuPrimitive.ItemIndicator },
+);
+
+/** The row that opens its submenu. */
+export const DropdownMenuSubTrigger = menuPart(
+ DropdownMenuPrimitive.SubTrigger,
+ "ui-menu__item",
+ { chevron: true },
+);
+
/** The floating menu surface, portalled to `body`. */
export function DropdownMenuContent({
className,
@@ -39,7 +83,7 @@ export function DropdownMenuContent({
);
}
-/** The floating submenu surface, opened by `DropdownMenuSubTrigger`. */
+/** The floating submenu surface. */
export function DropdownMenuSubContent({
className,
...props
@@ -58,52 +102,3 @@ export function DropdownMenuSubContent({
);
}
-
-/** One selectable action row; a leading `Icon` sits in the gutter. */
-export function DropdownMenuItem({
- className,
- ...props
-}: ComponentPropsWithRef<
- typeof DropdownMenuPrimitive.Item
->): React.JSX.Element {
- return (
-
- );
-}
-
-/** The row that opens its submenu; renders a trailing chevron. */
-export function DropdownMenuSubTrigger({
- className,
- children,
- ...props
-}: ComponentPropsWithRef<
- typeof DropdownMenuPrimitive.SubTrigger
->): React.JSX.Element {
- return (
-
- {children}
-
-
- );
-}
-
-/** Thin rule between groups of items. */
-export function DropdownMenuSeparator({
- className,
- ...props
-}: ComponentPropsWithRef<
- typeof DropdownMenuPrimitive.Separator
->): React.JSX.Element {
- return (
-
- );
-}
diff --git a/packages/ui/src/components/EmptyState/EmptyState.stories.tsx b/packages/ui/src/components/EmptyState/EmptyState.stories.tsx
index d2115447c1..5ba265869a 100644
--- a/packages/ui/src/components/EmptyState/EmptyState.stories.tsx
+++ b/packages/ui/src/components/EmptyState/EmptyState.stories.tsx
@@ -1,5 +1,7 @@
import { PIXEL_ALL_THEMES } from "#storybook";
+import { Button } from "../Button/Button";
+
import { EmptyState } from "./EmptyState";
import type { Meta, StoryObj } from "@storybook/react-vite";
@@ -9,7 +11,7 @@ const EmptyStates = (): React.JSX.Element => (
Create workspace}
+ action={}
/>
- {retryLabel}
-
- ) : undefined)
+ (onRetry ? : undefined)
}
/>
);
diff --git a/packages/ui/src/components/IconButton/IconButton.css b/packages/ui/src/components/IconButton/IconButton.css
index 721abb4635..487981abc7 100644
--- a/packages/ui/src/components/IconButton/IconButton.css
+++ b/packages/ui/src/components/IconButton/IconButton.css
@@ -5,27 +5,25 @@
color: var(--ui-toolbar-foreground);
background: transparent;
border: 0;
- border-radius: 5px;
+ border-radius: var(--ui-radius-medium);
cursor: pointer;
outline: 1px solid transparent;
outline-offset: -1px;
}
-.ui-icon-button:hover:not(:disabled) {
+/* One outline serves hover, toggled, and focus, so :where() keeps the
+ exclusions weightless and source order alone decides. */
+.ui-icon-button:hover:where(:not(:disabled, [aria-pressed="true"])) {
background: var(--ui-toolbar-hover-background);
outline-color: var(--ui-toolbar-hover-outline);
outline-style: dashed;
}
-.ui-icon-button:active:not(:disabled) {
+.ui-icon-button:active:where(:not(:disabled, [aria-pressed="true"])) {
background: var(--ui-toolbar-active-background);
}
-.ui-icon-button:focus-visible {
- outline-color: var(--ui-focus-border);
- outline-style: solid;
-}
-
+/* A toggled button keeps its active colors on hover, like native */
.ui-icon-button[aria-pressed="true"] {
color: var(--ui-input-option-active-foreground);
background: var(--ui-input-option-active-background);
@@ -33,7 +31,8 @@
outline-style: solid;
}
-.ui-icon-button:disabled {
- opacity: var(--ui-disabled-opacity);
- cursor: default;
+/* Last: native's ring is an outline, the others border colors, so it wins */
+.ui-icon-button:focus {
+ outline-color: var(--ui-focus-border);
+ outline-style: solid;
}
diff --git a/packages/ui/src/components/IconButton/IconButton.tsx b/packages/ui/src/components/IconButton/IconButton.tsx
index e700e1c166..e5d308661a 100644
--- a/packages/ui/src/components/IconButton/IconButton.tsx
+++ b/packages/ui/src/components/IconButton/IconButton.tsx
@@ -17,11 +17,12 @@ export interface IconButtonProps extends Omit<
label: string;
}
+/* No default title: native toolbar buttons hint with the styled hover
+ widget, not the browser box. Wrap in Tooltip for that. */
export function IconButton({
icon,
label,
className,
- title = label,
type = "button",
...props
}: IconButtonProps): React.JSX.Element {
@@ -29,7 +30,6 @@ export function IconButton({