diff --git a/packages/ui/src/components/toolbar.test.tsx b/packages/ui/src/components/toolbar.test.tsx
new file mode 100644
index 0000000..29e85c8
--- /dev/null
+++ b/packages/ui/src/components/toolbar.test.tsx
@@ -0,0 +1,368 @@
+import { describe, expect, it, vi } from "vitest"
+import { render, screen } from "@testing-library/react"
+import { userEvent } from "@testing-library/user-event"
+import { axe } from "vitest-axe"
+import { Toolbar } from "./toolbar"
+
+describe("Toolbar accessibility", () => {
+ it("has no accessibility violations in horizontal orientation", async () => {
+ const { container } = render(
+
+
+ Bold
+ Italic
+
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("has no accessibility violations in vertical orientation", async () => {
+ const { container } = render(
+
+ Edit
+ Delete
+
+ )
+ const results = await axe(container)
+ expect(results).toHaveNoViolations()
+ })
+
+ it("has proper ARIA role and orientation", () => {
+ render(
+
+ Zoom
+
+ )
+ const toolbar = screen.getByRole("toolbar", { name: "Chart controls" })
+ expect(toolbar).toHaveAttribute("aria-orientation", "horizontal")
+ })
+
+ it("groups have proper role", () => {
+ render(
+
+
+ Bold
+
+
+ )
+ expect(screen.getByRole("group", { name: "Text style" })).toBeInTheDocument()
+ })
+
+ it("separator has proper role and orientation", () => {
+ const { container } = render(
+
+ Cut
+
+ Paste
+
+ )
+ const separator = container.querySelector('[role="separator"]')
+ expect(separator).toBeInTheDocument()
+ expect(separator).toHaveAttribute("aria-orientation", "horizontal")
+ })
+})
+
+describe("Toolbar roving tabindex", () => {
+ it("only first enabled button is in tab order initially", () => {
+ render(
+
+ First
+ Second
+ Third
+
+ )
+ const buttons = screen.getAllByRole("button")
+ expect(buttons[0]).toHaveAttribute("tabindex", "0")
+ expect(buttons[1]).toHaveAttribute("tabindex", "-1")
+ expect(buttons[2]).toHaveAttribute("tabindex", "-1")
+ })
+
+ it("disabled buttons are never in tab order", () => {
+ render(
+
+ Disabled
+ Enabled
+
+ )
+ const buttons = screen.getAllByRole("button")
+ expect(buttons[0]).toHaveAttribute("tabindex", "-1")
+ expect(buttons[1]).toHaveAttribute("tabindex", "0")
+ })
+
+ it("ArrowRight moves focus in horizontal toolbar", async () => {
+ const user = userEvent.setup()
+ render(
+
+ First
+ Second
+ Third
+
+ )
+ const buttons = screen.getAllByRole("button")
+ buttons[0].focus()
+ await user.keyboard("{ArrowRight}")
+ expect(buttons[1]).toHaveFocus()
+ })
+
+ it("ArrowLeft moves focus backward in horizontal toolbar", async () => {
+ const user = userEvent.setup()
+ render(
+
+ First
+ Second
+
+ )
+ const buttons = screen.getAllByRole("button")
+ buttons[1].focus()
+ await user.keyboard("{ArrowLeft}")
+ expect(buttons[0]).toHaveFocus()
+ })
+
+ it("ArrowDown moves focus in vertical toolbar", async () => {
+ const user = userEvent.setup()
+ render(
+
+ First
+ Second
+
+ )
+ const buttons = screen.getAllByRole("button")
+ buttons[0].focus()
+ await user.keyboard("{ArrowDown}")
+ expect(buttons[1]).toHaveFocus()
+ })
+
+ it("ArrowUp moves focus backward in vertical toolbar", async () => {
+ const user = userEvent.setup()
+ render(
+
+ First
+ Second
+
+ )
+ const buttons = screen.getAllByRole("button")
+ buttons[1].focus()
+ await user.keyboard("{ArrowUp}")
+ expect(buttons[0]).toHaveFocus()
+ })
+
+ it("Home key moves to first enabled control", async () => {
+ const user = userEvent.setup()
+ render(
+
+ First
+ Second
+ Third
+
+ )
+ const buttons = screen.getAllByRole("button")
+ buttons[2].focus()
+ await user.keyboard("{Home}")
+ expect(buttons[0]).toHaveFocus()
+ })
+
+ it("End key moves to last enabled control", async () => {
+ const user = userEvent.setup()
+ render(
+
+ First
+ Second
+ Third
+
+ )
+ const buttons = screen.getAllByRole("button")
+ buttons[0].focus()
+ await user.keyboard("{End}")
+ expect(buttons[2]).toHaveFocus()
+ })
+
+ it("arrow keys wrap around at boundaries", async () => {
+ const user = userEvent.setup()
+ render(
+
+ First
+ Second
+
+ )
+ const buttons = screen.getAllByRole("button")
+
+ // Wrap from last to first
+ buttons[1].focus()
+ await user.keyboard("{ArrowRight}")
+ expect(buttons[0]).toHaveFocus()
+
+ // Wrap from first to last
+ await user.keyboard("{ArrowLeft}")
+ expect(buttons[1]).toHaveFocus()
+ })
+
+ it("skips disabled controls during navigation", async () => {
+ const user = userEvent.setup()
+ render(
+
+ First
+ Disabled
+ Third
+
+ )
+ const buttons = screen.getAllByRole("button")
+ buttons[0].focus()
+ await user.keyboard("{ArrowRight}")
+ expect(buttons[2]).toHaveFocus()
+ })
+})
+
+describe("Toolbar toggle buttons", () => {
+ it("supports pressed state", () => {
+ render(
+
+ Bold
+ Italic
+
+ )
+ const buttons = screen.getAllByRole("button")
+ expect(buttons[0]).toHaveAttribute("aria-pressed", "true")
+ expect(buttons[0]).toHaveAttribute("data-state", "on")
+ expect(buttons[1]).toHaveAttribute("aria-pressed", "false")
+ expect(buttons[1]).toHaveAttribute("data-state", "off")
+ })
+
+ it("toggle button interaction works", async () => {
+ const user = userEvent.setup()
+ const handleClick = vi.fn()
+ render(
+
+ Bold
+
+ )
+ await user.click(screen.getByRole("button", { name: "Bold" }))
+ expect(handleClick).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe("Toolbar links", () => {
+ it("renders links with proper semantics", () => {
+ render(
+
+ Documentation
+ Help
+
+ )
+ const links = screen.getAllByRole("link")
+ expect(links).toHaveLength(2)
+ expect(links[0]).toHaveAttribute("href", "/docs")
+ })
+
+ it("links participate in roving tabindex", () => {
+ render(
+
+ Docs
+ Help
+
+ )
+ const links = screen.getAllByRole("link")
+ expect(links[0]).toHaveAttribute("tabindex", "0")
+ expect(links[1]).toHaveAttribute("tabindex", "-1")
+ })
+
+ it("arrow keys navigate between links", async () => {
+ const user = userEvent.setup()
+ render(
+
+ Docs
+ Help
+
+ )
+ const links = screen.getAllByRole("link")
+ links[0].focus()
+ await user.keyboard("{ArrowRight}")
+ expect(links[1]).toHaveFocus()
+ })
+})
+
+describe("Toolbar with dynamic items", () => {
+ it("handles items being added dynamically", () => {
+ const { rerender } = render(
+
+ First
+
+ )
+
+ rerender(
+
+ First
+ Second
+
+ )
+
+ const buttons = screen.getAllByRole("button")
+ expect(buttons).toHaveLength(2)
+ })
+
+ it("handles items being removed dynamically", () => {
+ const { rerender } = render(
+
+ First
+ Second
+
+ )
+
+ rerender(
+
+ First
+
+ )
+
+ const buttons = screen.getAllByRole("button")
+ expect(buttons).toHaveLength(1)
+ })
+})
+
+describe("Toolbar variants and sizes", () => {
+ it("applies variant classes correctly", () => {
+ const { container } = render(
+
+ Default
+ Outline
+ Ghost
+
+ )
+ expect(container).toBeInTheDocument()
+ })
+
+ it("applies size classes correctly", () => {
+ const { container } = render(
+
+ Default
+ Small
+
+
+
+
+ )
+ expect(container).toBeInTheDocument()
+ })
+})
+
+describe("Toolbar mixed controls", () => {
+ it("handles mix of buttons and links", async () => {
+ const user = userEvent.setup()
+ render(
+
+ Edit
+ Help
+ Delete
+
+ )
+
+ const button = screen.getByRole("button", { name: "Edit" })
+ button.focus()
+ await user.keyboard("{ArrowRight}")
+ expect(screen.getByRole("link", { name: "Help" })).toHaveFocus()
+
+ await user.keyboard("{ArrowRight}")
+ expect(screen.getByRole("button", { name: "Delete" })).toHaveFocus()
+ })
+})
diff --git a/packages/ui/src/components/toolbar.tsx b/packages/ui/src/components/toolbar.tsx
new file mode 100644
index 0000000..9e1da1c
--- /dev/null
+++ b/packages/ui/src/components/toolbar.tsx
@@ -0,0 +1,391 @@
+"use client"
+
+import * as React from "react"
+import { Button as ButtonPrimitive } from "@base-ui/react/button"
+import { cva, type VariantProps } from "class-variance-authority"
+
+import { cn } from "@workspace/ui/lib/utils"
+
+/* ────────────────────────────────────────────────────────────────────────────
+ * Toolbar Context
+ * ──────────────────────────────────────────────────────────────────────────── */
+
+interface ToolbarContextValue {
+ orientation: "horizontal" | "vertical"
+ rovingTabIndex: number
+ setRovingTabIndex: (index: number) => void
+ items: React.RefObject[]
+ registerItem: (ref: React.RefObject) => () => void
+}
+
+const ToolbarContext = React.createContext(null)
+
+function useToolbarContext() {
+ const context = React.useContext(ToolbarContext)
+ if (!context) {
+ throw new Error("Toolbar compound components must be used within Toolbar.Root")
+ }
+ return context
+}
+
+/* ────────────────────────────────────────────────────────────────────────────
+ * Toolbar Root
+ * ──────────────────────────────────────────────────────────────────────────── */
+
+interface ToolbarRootProps extends React.ComponentPropsWithoutRef<"div"> {
+ orientation?: "horizontal" | "vertical"
+ "aria-label": string
+}
+
+const ToolbarRoot = React.forwardRef(
+ ({ className, orientation = "horizontal", children, ...props }, ref) => {
+ const [rovingTabIndex, setRovingTabIndex] = React.useState(-1)
+ const itemsRef = React.useRef[]>([])
+ const [initialized, setInitialized] = React.useState(false)
+
+ const registerItem = React.useCallback(
+ (itemRef: React.RefObject) => {
+ itemsRef.current.push(itemRef)
+ return () => {
+ itemsRef.current = itemsRef.current.filter((ref) => ref !== itemRef)
+ }
+ },
+ []
+ )
+
+ // Initialize roving tab index to first enabled item
+ React.useEffect(() => {
+ if (!initialized && itemsRef.current.length > 0) {
+ const firstEnabledIndex = itemsRef.current.findIndex((item) => {
+ const el = item.current
+ return el && !el.hasAttribute("disabled") && !el.getAttribute("aria-disabled")
+ })
+ if (firstEnabledIndex !== -1) {
+ setRovingTabIndex(firstEnabledIndex)
+ setInitialized(true)
+ }
+ }
+ }, [initialized, itemsRef.current.length])
+
+ const contextValue: ToolbarContextValue = React.useMemo(
+ () => ({
+ orientation,
+ rovingTabIndex,
+ setRovingTabIndex,
+ items: itemsRef.current,
+ registerItem,
+ }),
+ [orientation, rovingTabIndex, registerItem]
+ )
+
+ return (
+
+
+ {children}
+
+
+ )
+ }
+)
+ToolbarRoot.displayName = "Toolbar.Root"
+
+/* ────────────────────────────────────────────────────────────────────────────
+ * Toolbar Group
+ * ──────────────────────────────────────────────────────────────────────────── */
+
+interface ToolbarGroupProps extends React.ComponentPropsWithoutRef<"div"> {
+ "aria-label"?: string
+}
+
+const ToolbarGroup = React.forwardRef(
+ ({ className, children, ...props }, ref) => {
+ const { orientation } = useToolbarContext()
+
+ return (
+
+ {children}
+
+ )
+ }
+)
+ToolbarGroup.displayName = "Toolbar.Group"
+
+/* ────────────────────────────────────────────────────────────────────────────
+ * Toolbar Button
+ * ──────────────────────────────────────────────────────────────────────────── */
+
+const toolbarButtonVariants = cva(
+ "inline-flex shrink-0 items-center justify-center rounded border border-transparent text-xs font-medium whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-muted hover:text-foreground data-state-on:bg-accent data-state-on:text-accent-foreground",
+ outline: "border-border hover:bg-input/50 data-state-on:bg-muted data-state-on:border-border",
+ ghost: "hover:bg-muted hover:text-foreground data-state-on:bg-accent",
+ },
+ size: {
+ default: "h-7 px-2 gap-1",
+ sm: "h-6 px-1.5 gap-0.5 text-10",
+ icon: "size-7",
+ "icon-sm": "size-6",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+)
+
+interface ToolbarButtonProps
+ extends Omit,
+ VariantProps {
+ pressed?: boolean
+}
+
+const ToolbarButton = React.forwardRef(
+ ({ className, variant = "default", size = "default", pressed, disabled, onKeyDown, ...props }, ref) => {
+ const { orientation, rovingTabIndex, setRovingTabIndex, items, registerItem } = useToolbarContext()
+ const buttonRef = React.useRef(null)
+ const [localIndex, setLocalIndex] = React.useState(-1)
+
+ // Register this button and get its index
+ React.useEffect(() => {
+ const unregister = registerItem(buttonRef as React.RefObject)
+ const index = items.findIndex((item) => item === buttonRef)
+ setLocalIndex(index)
+ return unregister
+ }, [registerItem, items])
+
+ // Handle keyboard navigation
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ onKeyDown?.(event)
+
+ const enabledItems = items.filter((item) => {
+ const el = item.current
+ return el && !el.hasAttribute("disabled") && !el.getAttribute("aria-disabled")
+ })
+
+ const currentIndex = enabledItems.findIndex((item) => item === buttonRef)
+ if (currentIndex === -1) return
+
+ let nextIndex = currentIndex
+
+ if (orientation === "horizontal") {
+ if (event.key === "ArrowRight") {
+ event.preventDefault()
+ nextIndex = (currentIndex + 1) % enabledItems.length
+ } else if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ nextIndex = (currentIndex - 1 + enabledItems.length) % enabledItems.length
+ }
+ } else {
+ if (event.key === "ArrowDown") {
+ event.preventDefault()
+ nextIndex = (currentIndex + 1) % enabledItems.length
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault()
+ nextIndex = (currentIndex - 1 + enabledItems.length) % enabledItems.length
+ }
+ }
+
+ if (event.key === "Home") {
+ event.preventDefault()
+ nextIndex = 0
+ } else if (event.key === "End") {
+ event.preventDefault()
+ nextIndex = enabledItems.length - 1
+ }
+
+ if (nextIndex !== currentIndex) {
+ const nextItem = enabledItems[nextIndex]
+ const globalIndex = items.findIndex((item) => item === nextItem)
+ setRovingTabIndex(globalIndex)
+ nextItem.current?.focus()
+ }
+ },
+ [items, orientation, onKeyDown, setRovingTabIndex]
+ )
+
+ return (
+ {
+ // @ts-expect-error - ref forwarding
+ buttonRef.current = node
+ if (typeof ref === "function") {
+ ref(node)
+ } else if (ref) {
+ ref.current = node
+ }
+ }}
+ data-slot="toolbar-button"
+ data-state={pressed ? "on" : "off"}
+ aria-pressed={pressed !== undefined ? pressed : undefined}
+ tabIndex={!disabled && localIndex === rovingTabIndex ? 0 : -1}
+ disabled={disabled}
+ onKeyDown={handleKeyDown}
+ className={cn(toolbarButtonVariants({ variant, size }), className)}
+ {...props}
+ />
+ )
+ }
+)
+ToolbarButton.displayName = "Toolbar.Button"
+
+/* ────────────────────────────────────────────────────────────────────────────
+ * Toolbar Link
+ * ──────────────────────────────────────────────────────────────────────────── */
+
+interface ToolbarLinkProps extends React.ComponentPropsWithoutRef<"a">, VariantProps {}
+
+const ToolbarLink = React.forwardRef(
+ ({ className, variant = "default", size = "default", onKeyDown, ...props }, ref) => {
+ const { orientation, rovingTabIndex, setRovingTabIndex, items, registerItem } = useToolbarContext()
+ const linkRef = React.useRef(null)
+ const [localIndex, setLocalIndex] = React.useState(-1)
+
+ React.useEffect(() => {
+ const unregister = registerItem(linkRef as React.RefObject)
+ const index = items.findIndex((item) => item === linkRef)
+ setLocalIndex(index)
+ return unregister
+ }, [registerItem, items])
+
+ const handleKeyDown = React.useCallback(
+ (event: React.KeyboardEvent) => {
+ onKeyDown?.(event)
+
+ const enabledItems = items.filter((item) => item.current)
+ const currentIndex = enabledItems.findIndex((item) => item === linkRef)
+ if (currentIndex === -1) return
+
+ let nextIndex = currentIndex
+
+ if (orientation === "horizontal") {
+ if (event.key === "ArrowRight") {
+ event.preventDefault()
+ nextIndex = (currentIndex + 1) % enabledItems.length
+ } else if (event.key === "ArrowLeft") {
+ event.preventDefault()
+ nextIndex = (currentIndex - 1 + enabledItems.length) % enabledItems.length
+ }
+ } else {
+ if (event.key === "ArrowDown") {
+ event.preventDefault()
+ nextIndex = (currentIndex + 1) % enabledItems.length
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault()
+ nextIndex = (currentIndex - 1 + enabledItems.length) % enabledItems.length
+ }
+ }
+
+ if (event.key === "Home") {
+ event.preventDefault()
+ nextIndex = 0
+ } else if (event.key === "End") {
+ event.preventDefault()
+ nextIndex = enabledItems.length - 1
+ }
+
+ if (nextIndex !== currentIndex) {
+ const nextItem = enabledItems[nextIndex]
+ const globalIndex = items.findIndex((item) => item === nextItem)
+ setRovingTabIndex(globalIndex)
+ nextItem.current?.focus()
+ }
+ },
+ [items, orientation, onKeyDown, setRovingTabIndex]
+ )
+
+ return (
+ {
+ // @ts-expect-error - ref forwarding
+ linkRef.current = node
+ if (typeof ref === "function") {
+ ref(node)
+ } else if (ref) {
+ ref.current = node
+ }
+ }}
+ data-slot="toolbar-link"
+ tabIndex={localIndex === rovingTabIndex ? 0 : -1}
+ onKeyDown={handleKeyDown}
+ className={cn(toolbarButtonVariants({ variant, size }), className)}
+ {...props}
+ />
+ )
+ }
+)
+ToolbarLink.displayName = "Toolbar.Link"
+
+/* ────────────────────────────────────────────────────────────────────────────
+ * Toolbar Separator
+ * ──────────────────────────────────────────────────────────────────────────── */
+
+interface ToolbarSeparatorProps extends React.ComponentPropsWithoutRef<"div"> {}
+
+const ToolbarSeparator = React.forwardRef(
+ ({ className, ...props }, ref) => {
+ const { orientation } = useToolbarContext()
+
+ return (
+
+ )
+ }
+)
+ToolbarSeparator.displayName = "Toolbar.Separator"
+
+/* ────────────────────────────────────────────────────────────────────────────
+ * Exports
+ * ──────────────────────────────────────────────────────────────────────────── */
+
+export const Toolbar = {
+ Root: ToolbarRoot,
+ Group: ToolbarGroup,
+ Button: ToolbarButton,
+ Link: ToolbarLink,
+ Separator: ToolbarSeparator,
+}
+
+export type {
+ ToolbarRootProps,
+ ToolbarGroupProps,
+ ToolbarButtonProps,
+ ToolbarLinkProps,
+ ToolbarSeparatorProps,
+}