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, +}