Toolbar
A container for grouping a set of controls, such as buttons, toggle buttons, or other input elements.
Demo
Text Formatting Toolbar
A horizontal toolbar with toggle buttons and regular buttons.
Vertical Toolbar
Use arrow up/down keys to navigate.
With Disabled Items
Disabled items are skipped during keyboard navigation.
Controlled Toggle Buttons
Toggle buttons with controlled state. The current state is displayed and applied to the sample text.
Current state: {"bold":false,"italic":false,"underline":false}
Default Pressed States
Toggle buttons with defaultPressed for initial state, including disabled states.
Accessibility
WAI-ARIA Roles
| Role | Target Element | Description |
|---|---|---|
toolbar | Container | Container for grouping controls |
button | Button elements | Implicit role for <button> elements |
separator | Separator | Visual and semantic separator between groups |
WAI-ARIA toolbar role (opens in new tab)
WAI-ARIA Properties
| Attribute | Target | Values | Required | Configuration |
|---|---|---|---|---|
aria-label | toolbar | String | Yes* | aria-label prop |
aria-labelledby | toolbar | ID reference | Yes* | aria-labelledby prop |
aria-orientation | toolbar | "horizontal" | "vertical" | No | orientation prop (default: horizontal) |
* Either aria-label or aria-labelledby is required
WAI-ARIA States
aria-pressed
Indicates the pressed state of toggle buttons.
| Target | ToolbarToggleButton |
| Values | true | false |
| Required | Yes (for toggle buttons) |
| Change Trigger | Click, Enter, Space |
| Reference | aria-pressed (opens in new tab) |
Keyboard Support
| Key | Action |
|---|---|
| Tab | Move focus into/out of the toolbar (single tab stop) |
| Arrow Right / Arrow Left | Navigate between controls (horizontal toolbar) |
| Arrow Down / Arrow Up | Navigate between controls (vertical toolbar) |
| Home | Move focus to first control |
| End | Move focus to last control |
| Enter / Space | Activate button / toggle pressed state |
Focus Management
This component uses the Roving Tabindex pattern for focus management:
- Only one control has
tabindex="0"at a time - Other controls have
tabindex="-1" - Arrow keys move focus between controls
- Disabled controls and separators are skipped
- Focus does not wrap (stops at edges)
Source Code
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
/**
* Toolbar context for managing focus state
*/
interface ToolbarContextValue {
orientation: "horizontal" | "vertical";
}
// Default context value for SSR compatibility
const defaultContext: ToolbarContextValue = {
orientation: "horizontal",
};
const ToolbarContext = createContext<ToolbarContextValue>(defaultContext);
function useToolbarContext() {
return useContext(ToolbarContext);
}
/**
* Props for the Toolbar component
* @see https://www.w3.org/WAI/ARIA/apg/patterns/toolbar/
*/
export interface ToolbarProps
extends Omit<React.HTMLAttributes<HTMLDivElement>, "role"> {
/** Direction of the toolbar */
orientation?: "horizontal" | "vertical";
/** Child elements (ToolbarButton, ToolbarToggleButton, ToolbarSeparator) */
children: React.ReactNode;
}
/**
* Toolbar container component implementing WAI-ARIA Toolbar pattern
*
* @example
* ```tsx
* <Toolbar aria-label="Text formatting">
* <ToolbarToggleButton>Bold</ToolbarToggleButton>
* <ToolbarToggleButton>Italic</ToolbarToggleButton>
* <ToolbarSeparator />
* <ToolbarButton>Copy</ToolbarButton>
* </Toolbar>
* ```
*/
export function Toolbar({
orientation = "horizontal",
children,
className = "",
onKeyDown,
...props
}: ToolbarProps): React.ReactElement {
const toolbarRef = useRef<HTMLDivElement>(null);
const [focusedIndex, setFocusedIndex] = useState(0);
const getButtons = useCallback((): HTMLButtonElement[] => {
if (!toolbarRef.current) return [];
return Array.from(
toolbarRef.current.querySelectorAll<HTMLButtonElement>(
"button:not([disabled])"
)
);
}, []);
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const buttons = getButtons();
if (buttons.length === 0) return;
const currentIndex = buttons.findIndex(
(btn) => btn === document.activeElement
);
if (currentIndex === -1) return;
const nextKey = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
const prevKey = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
const invalidKeys =
orientation === "vertical"
? ["ArrowLeft", "ArrowRight"]
: ["ArrowUp", "ArrowDown"];
// Ignore invalid direction keys
if (invalidKeys.includes(event.key)) {
return;
}
let newIndex = currentIndex;
let shouldPreventDefault = false;
switch (event.key) {
case nextKey:
// No wrap - stop at end
if (currentIndex < buttons.length - 1) {
newIndex = currentIndex + 1;
}
shouldPreventDefault = true;
break;
case prevKey:
// No wrap - stop at start
if (currentIndex > 0) {
newIndex = currentIndex - 1;
}
shouldPreventDefault = true;
break;
case "Home":
newIndex = 0;
shouldPreventDefault = true;
break;
case "End":
newIndex = buttons.length - 1;
shouldPreventDefault = true;
break;
}
if (shouldPreventDefault) {
event.preventDefault();
if (newIndex !== currentIndex) {
buttons[newIndex].focus();
setFocusedIndex(newIndex);
}
}
onKeyDown?.(event);
},
[orientation, getButtons, onKeyDown]
);
const handleFocus = useCallback(
(event: React.FocusEvent<HTMLDivElement>) => {
const { target } = event;
if (!(target instanceof HTMLButtonElement)) return;
const buttons = getButtons();
const targetIndex = buttons.findIndex((btn) => btn === target);
if (targetIndex !== -1) {
setFocusedIndex(targetIndex);
}
},
[getButtons]
);
// Roving tabindex: only the focused button should have tabIndex=0
useEffect(() => {
const buttons = getButtons();
if (buttons.length === 0) return;
// Clamp focusedIndex to valid range
const validIndex = Math.min(focusedIndex, buttons.length - 1);
if (validIndex !== focusedIndex) {
setFocusedIndex(validIndex);
return; // Will re-run with corrected index
}
buttons.forEach((btn, index) => {
btn.tabIndex = index === focusedIndex ? 0 : -1;
});
}, [focusedIndex, getButtons, children]);
return (
<ToolbarContext.Provider value={{ orientation }}>
<div
ref={toolbarRef}
role="toolbar"
aria-orientation={orientation}
className={`apg-toolbar ${className}`.trim()}
onKeyDown={handleKeyDown}
onFocus={handleFocus}
{...props}
>
{children}
</div>
</ToolbarContext.Provider>
);
}
/**
* Props for the ToolbarButton component
*/
export interface ToolbarButtonProps
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "type"> {
/** Button content */
children: React.ReactNode;
}
/**
* Button component for use within a Toolbar
*/
export function ToolbarButton({
children,
className = "",
disabled,
...props
}: ToolbarButtonProps): React.ReactElement {
// Verify we're inside a Toolbar
useToolbarContext();
return (
<button
type="button"
className={`apg-toolbar-button ${className}`.trim()}
disabled={disabled}
{...props}
>
{children}
</button>
);
}
/**
* Props for the ToolbarToggleButton component
*/
export interface ToolbarToggleButtonProps
extends Omit<
React.ButtonHTMLAttributes<HTMLButtonElement>,
"type" | "aria-pressed"
> {
/** Controlled pressed state */
pressed?: boolean;
/** Default pressed state (uncontrolled) */
defaultPressed?: boolean;
/** Callback when pressed state changes */
onPressedChange?: (pressed: boolean) => void;
/** Button content */
children: React.ReactNode;
}
/**
* Toggle button component for use within a Toolbar
*/
export function ToolbarToggleButton({
pressed: controlledPressed,
defaultPressed = false,
onPressedChange,
children,
className = "",
disabled,
onClick,
...props
}: ToolbarToggleButtonProps): React.ReactElement {
// Verify we're inside a Toolbar
useToolbarContext();
const [internalPressed, setInternalPressed] = useState(defaultPressed);
const isControlled = controlledPressed !== undefined;
const pressed = isControlled ? controlledPressed : internalPressed;
const handleClick = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
if (disabled) return;
const newPressed = !pressed;
if (!isControlled) {
setInternalPressed(newPressed);
}
onPressedChange?.(newPressed);
onClick?.(event);
},
[disabled, pressed, isControlled, onPressedChange, onClick]
);
return (
<button
type="button"
aria-pressed={pressed}
className={`apg-toolbar-button ${className}`.trim()}
disabled={disabled}
onClick={handleClick}
{...props}
>
{children}
</button>
);
}
/**
* Props for the ToolbarSeparator component
*/
export interface ToolbarSeparatorProps {
/** Additional CSS class */
className?: string;
}
/**
* Separator component for use within a Toolbar
*/
export function ToolbarSeparator({
className = "",
}: ToolbarSeparatorProps): React.ReactElement {
const { orientation } = useToolbarContext();
// Separator orientation is perpendicular to toolbar orientation
const separatorOrientation =
orientation === "horizontal" ? "vertical" : "horizontal";
return (
<div
role="separator"
aria-orientation={separatorOrientation}
className={`apg-toolbar-separator ${className}`.trim()}
/>
);
} Usage
import {
Toolbar,
ToolbarButton,
ToolbarToggleButton,
ToolbarSeparator
} from '@patterns/toolbar/Toolbar';
// Basic usage
<Toolbar aria-label="Text formatting">
<ToolbarToggleButton>Bold</ToolbarToggleButton>
<ToolbarToggleButton>Italic</ToolbarToggleButton>
<ToolbarSeparator />
<ToolbarButton>Copy</ToolbarButton>
<ToolbarButton>Paste</ToolbarButton>
</Toolbar>
// Vertical toolbar
<Toolbar orientation="vertical" aria-label="Actions">
<ToolbarButton>New</ToolbarButton>
<ToolbarButton>Open</ToolbarButton>
<ToolbarButton>Save</ToolbarButton>
</Toolbar>
// Controlled toggle button
const [isBold, setIsBold] = useState(false);
<Toolbar aria-label="Formatting">
<ToolbarToggleButton
pressed={isBold}
onPressedChange={setIsBold}
>
Bold
</ToolbarToggleButton>
</Toolbar> API
Toolbar Props
| Prop | Type | Default | Description |
|---|---|---|---|
orientation | 'horizontal' | 'vertical' | 'horizontal' | Direction of the toolbar |
aria-label | string | - | Accessible label for the toolbar |
children | React.ReactNode | - | Toolbar content |
ToolbarButton Props
| Prop | Type | Default | Description |
|---|---|---|---|
disabled | boolean | false | Whether the button is disabled |
onClick | () => void | - | Click handler |
ToolbarToggleButton Props
| Prop | Type | Default | Description |
|---|---|---|---|
pressed | boolean | - | Controlled pressed state |
defaultPressed | boolean | false | Initial pressed state (uncontrolled) |
onPressedChange | (pressed: boolean) => void | - | Callback when pressed state changes |
disabled | boolean | false | Whether the button is disabled |
Testing
Tests verify APG compliance across keyboard interaction, ARIA attributes, and accessibility requirements.
Test Categories
High Priority: APG Keyboard Interaction
| Test | Description |
|---|---|
ArrowRight/Left | Moves focus between items (horizontal) |
ArrowDown/Up | Moves focus between items (vertical) |
Home | Moves focus to first item |
End | Moves focus to last item |
No wrap | Focus stops at edges (no looping) |
Disabled skip | Skips disabled items during navigation |
Enter/Space | Activates button or toggles toggle button |
High Priority: APG ARIA Attributes
| Test | Description |
|---|---|
role="toolbar" | Container has toolbar role |
aria-orientation | Reflects horizontal/vertical orientation |
aria-label/labelledby | Toolbar has accessible name |
aria-pressed | Toggle buttons reflect pressed state |
role="separator" | Separator has correct role and orientation |
type="button" | Buttons have explicit type attribute |
High Priority: Focus Management (Roving Tabindex)
| Test | Description |
|---|---|
tabIndex=0 | First enabled item has tabIndex=0 |
tabIndex=-1 | Other items have tabIndex=-1 |
Click updates focus | Clicking an item updates roving focus position |
Medium Priority: Accessibility
| Test | Description |
|---|---|
axe violations | No WCAG 2.1 AA violations (via jest-axe) |
Vertical toolbar | Vertical orientation also passes axe |
Low Priority: HTML Attribute Inheritance
| Test | Description |
|---|---|
className | Custom classes applied to all components |
Testing Tools
- Vitest (opens in new tab) - Test runner
- Testing Library (opens in new tab) - Framework-specific testing utilities
- jest-axe (opens in new tab) - Automated accessibility testing
See testing-strategy.md (opens in new tab) for full documentation.
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe } from "jest-axe";
import { describe, expect, it, vi } from "vitest";
import {
Toolbar,
ToolbarButton,
ToolbarToggleButton,
ToolbarSeparator,
} from "./Toolbar";
describe("Toolbar", () => {
// ๐ด High Priority: APG ๆบๆ ใฎๆ ธๅฟ
describe("APG: ARIA ๅฑๆง", () => {
it('role="toolbar" ใ่จญๅฎใใใ', () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>Button</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("toolbar")).toBeInTheDocument();
});
it('aria-orientation ใใใใฉใซใใง "horizontal"', () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>Button</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("toolbar")).toHaveAttribute(
"aria-orientation",
"horizontal"
);
});
it('aria-orientation ใ orientation prop ใๅๆ ใใ', () => {
const { rerender } = render(
<Toolbar aria-label="Test toolbar" orientation="vertical">
<ToolbarButton>Button</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("toolbar")).toHaveAttribute(
"aria-orientation",
"vertical"
);
rerender(
<Toolbar aria-label="Test toolbar" orientation="horizontal">
<ToolbarButton>Button</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("toolbar")).toHaveAttribute(
"aria-orientation",
"horizontal"
);
});
it("aria-label ใ้้ใใใ", () => {
render(
<Toolbar aria-label="Text formatting">
<ToolbarButton>Button</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("toolbar")).toHaveAttribute(
"aria-label",
"Text formatting"
);
});
it("aria-labelledby ใ้้ใใใ", () => {
render(
<>
<h2 id="toolbar-label">Toolbar Label</h2>
<Toolbar aria-labelledby="toolbar-label">
<ToolbarButton>Button</ToolbarButton>
</Toolbar>
</>
);
expect(screen.getByRole("toolbar")).toHaveAttribute(
"aria-labelledby",
"toolbar-label"
);
});
});
describe("APG: ใญใผใใผใๆไฝ (Horizontal)", () => {
it("ArrowRight ใงๆฌกใฎใใฟใณใซใใฉใผใซใน็งปๅ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
<ToolbarButton>Third</ToolbarButton>
</Toolbar>
);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("button", { name: "Second" })).toHaveFocus();
});
it("ArrowLeft ใงๅใฎใใฟใณใซใใฉใผใซใน็งปๅ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
<ToolbarButton>Third</ToolbarButton>
</Toolbar>
);
const secondButton = screen.getByRole("button", { name: "Second" });
secondButton.focus();
await user.keyboard("{ArrowLeft}");
expect(screen.getByRole("button", { name: "First" })).toHaveFocus();
});
it("ArrowRight ใงๆๅพใใๅ
้ ญใซใฉใใใใชใ๏ผ็ซฏใงๆญขใพใ๏ผ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
<ToolbarButton>Third</ToolbarButton>
</Toolbar>
);
const thirdButton = screen.getByRole("button", { name: "Third" });
thirdButton.focus();
await user.keyboard("{ArrowRight}");
expect(thirdButton).toHaveFocus();
});
it("ArrowLeft ใงๅ
้ ญใใๆๅพใซใฉใใใใชใ๏ผ็ซฏใงๆญขใพใ๏ผ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
<ToolbarButton>Third</ToolbarButton>
</Toolbar>
);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{ArrowLeft}");
expect(firstButton).toHaveFocus();
});
it("ArrowUp/Down ใฏๆฐดๅนณใใผใซใใผใงใฏ็กๅน", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
</Toolbar>
);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{ArrowDown}");
expect(firstButton).toHaveFocus();
await user.keyboard("{ArrowUp}");
expect(firstButton).toHaveFocus();
});
it("Home ใงๆๅใฎใใฟใณใซใใฉใผใซใน็งปๅ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
<ToolbarButton>Third</ToolbarButton>
</Toolbar>
);
const thirdButton = screen.getByRole("button", { name: "Third" });
thirdButton.focus();
await user.keyboard("{Home}");
expect(screen.getByRole("button", { name: "First" })).toHaveFocus();
});
it("End ใงๆๅพใฎใใฟใณใซใใฉใผใซใน็งปๅ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
<ToolbarButton>Third</ToolbarButton>
</Toolbar>
);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{End}");
expect(screen.getByRole("button", { name: "Third" })).toHaveFocus();
});
it("disabled ใขใคใใ ใในใญใใใใฆ็งปๅ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton disabled>Second (disabled)</ToolbarButton>
<ToolbarButton>Third</ToolbarButton>
</Toolbar>
);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("button", { name: "Third" })).toHaveFocus();
});
});
describe("APG: ใญใผใใผใๆไฝ (Vertical)", () => {
it("ArrowDown ใงๆฌกใฎใใฟใณใซใใฉใผใซใน็งปๅ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar" orientation="vertical">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
<ToolbarButton>Third</ToolbarButton>
</Toolbar>
);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{ArrowDown}");
expect(screen.getByRole("button", { name: "Second" })).toHaveFocus();
});
it("ArrowUp ใงๅใฎใใฟใณใซใใฉใผใซใน็งปๅ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar" orientation="vertical">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
<ToolbarButton>Third</ToolbarButton>
</Toolbar>
);
const secondButton = screen.getByRole("button", { name: "Second" });
secondButton.focus();
await user.keyboard("{ArrowUp}");
expect(screen.getByRole("button", { name: "First" })).toHaveFocus();
});
it("ArrowLeft/Right ใฏๅ็ดใใผใซใใผใงใฏ็กๅน", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar" orientation="vertical">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
</Toolbar>
);
const firstButton = screen.getByRole("button", { name: "First" });
firstButton.focus();
await user.keyboard("{ArrowRight}");
expect(firstButton).toHaveFocus();
await user.keyboard("{ArrowLeft}");
expect(firstButton).toHaveFocus();
});
it("ArrowDown ใง็ซฏใงๆญขใพใ๏ผใฉใใใใชใ๏ผ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar" orientation="vertical">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
</Toolbar>
);
const secondButton = screen.getByRole("button", { name: "Second" });
secondButton.focus();
await user.keyboard("{ArrowDown}");
expect(secondButton).toHaveFocus();
});
});
describe("APG: ใใฉใผใซใน็ฎก็", () => {
it("ๆๅใฎๆๅนใชใขใคใใ ใ tabIndex=0ใไปใฏ tabIndex=-1 (Roving Tabindex)", () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
</Toolbar>
);
const buttons = screen.getAllByRole("button");
expect(buttons[0]).toHaveAttribute("tabIndex", "0");
expect(buttons[1]).toHaveAttribute("tabIndex", "-1");
});
it("ใฏใชใใฏใงใใฉใผใซในไฝ็ฝฎใๆดๆฐใใใ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>First</ToolbarButton>
<ToolbarButton>Second</ToolbarButton>
<ToolbarButton>Third</ToolbarButton>
</Toolbar>
);
await user.click(screen.getByRole("button", { name: "Second" }));
await user.keyboard("{ArrowRight}");
expect(screen.getByRole("button", { name: "Third" })).toHaveFocus();
});
});
});
describe("ToolbarButton", () => {
describe("ARIA ๅฑๆง", () => {
it('role="button" ใๆ้ป็ใซ่จญๅฎใใใ', () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>Click me</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("button", { name: "Click me" })).toBeInTheDocument();
});
it('type="button" ใ่จญๅฎใใใ', () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>Click me</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("button")).toHaveAttribute("type", "button");
});
});
describe("ๆฉ่ฝ", () => {
it("ใฏใชใใฏใง onClick ใ็บ็ซ", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton onClick={handleClick}>Click me</ToolbarButton>
</Toolbar>
);
await user.click(screen.getByRole("button"));
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("Enter ใง onClick ใ็บ็ซ", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton onClick={handleClick}>Click me</ToolbarButton>
</Toolbar>
);
const button = screen.getByRole("button");
button.focus();
await user.keyboard("{Enter}");
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("Space ใง onClick ใ็บ็ซ", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton onClick={handleClick}>Click me</ToolbarButton>
</Toolbar>
);
const button = screen.getByRole("button");
button.focus();
await user.keyboard(" ");
expect(handleClick).toHaveBeenCalledTimes(1);
});
it("disabled ๆใฏ onClick ใ็บ็ซใใชใ", async () => {
const handleClick = vi.fn();
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton onClick={handleClick} disabled>
Click me
</ToolbarButton>
</Toolbar>
);
await user.click(screen.getByRole("button"));
expect(handleClick).not.toHaveBeenCalled();
});
it("disabled ๆใฏใใฉใผใซในๅฏพ่ฑกๅค๏ผdisabledๅฑๆงใง้ใใฉใผใซใน๏ผ", () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton disabled>Click me</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("button")).toBeDisabled();
});
});
});
describe("ToolbarToggleButton", () => {
describe("ARIA ๅฑๆง", () => {
it('role="button" ใๆ้ป็ใซ่จญๅฎใใใ', () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton>Toggle</ToolbarToggleButton>
</Toolbar>
);
expect(screen.getByRole("button", { name: "Toggle" })).toBeInTheDocument();
});
it('type="button" ใ่จญๅฎใใใ', () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton>Toggle</ToolbarToggleButton>
</Toolbar>
);
expect(screen.getByRole("button")).toHaveAttribute("type", "button");
});
it('aria-pressed="false" ใๅๆ็ถๆ
ใง่จญๅฎใใใ', () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton>Toggle</ToolbarToggleButton>
</Toolbar>
);
expect(screen.getByRole("button")).toHaveAttribute("aria-pressed", "false");
});
it('aria-pressed="true" ใๆผไธ็ถๆ
ใง่จญๅฎใใใ', () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton defaultPressed>Toggle</ToolbarToggleButton>
</Toolbar>
);
expect(screen.getByRole("button")).toHaveAttribute("aria-pressed", "true");
});
});
describe("ๆฉ่ฝ", () => {
it("ใฏใชใใฏใง aria-pressed ใใใฐใซ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton>Toggle</ToolbarToggleButton>
</Toolbar>
);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-pressed", "false");
await user.click(button);
expect(button).toHaveAttribute("aria-pressed", "true");
await user.click(button);
expect(button).toHaveAttribute("aria-pressed", "false");
});
it("Enter ใง aria-pressed ใใใฐใซ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton>Toggle</ToolbarToggleButton>
</Toolbar>
);
const button = screen.getByRole("button");
button.focus();
expect(button).toHaveAttribute("aria-pressed", "false");
await user.keyboard("{Enter}");
expect(button).toHaveAttribute("aria-pressed", "true");
});
it("Space ใง aria-pressed ใใใฐใซ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton>Toggle</ToolbarToggleButton>
</Toolbar>
);
const button = screen.getByRole("button");
button.focus();
expect(button).toHaveAttribute("aria-pressed", "false");
await user.keyboard(" ");
expect(button).toHaveAttribute("aria-pressed", "true");
});
it("onPressedChange ใ็บ็ซ", async () => {
const handlePressedChange = vi.fn();
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton onPressedChange={handlePressedChange}>
Toggle
</ToolbarToggleButton>
</Toolbar>
);
await user.click(screen.getByRole("button"));
expect(handlePressedChange).toHaveBeenCalledWith(true);
await user.click(screen.getByRole("button"));
expect(handlePressedChange).toHaveBeenCalledWith(false);
});
it("defaultPressed ใงๅๆ็ถๆ
ใ่จญๅฎ", () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton defaultPressed>Toggle</ToolbarToggleButton>
</Toolbar>
);
expect(screen.getByRole("button")).toHaveAttribute("aria-pressed", "true");
});
it("pressed ใงๅถๅพกใใใ็ถๆ
", async () => {
const user = userEvent.setup();
const Controlled = () => {
const [pressed, setPressed] = React.useState(false);
return (
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton pressed={pressed} onPressedChange={setPressed}>
Toggle
</ToolbarToggleButton>
</Toolbar>
);
};
render(<Controlled />);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-pressed", "false");
await user.click(button);
expect(button).toHaveAttribute("aria-pressed", "true");
});
it("disabled ๆใฏใใฐใซใใชใ", async () => {
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton disabled>Toggle</ToolbarToggleButton>
</Toolbar>
);
const button = screen.getByRole("button");
expect(button).toHaveAttribute("aria-pressed", "false");
await user.click(button);
expect(button).toHaveAttribute("aria-pressed", "false");
});
it("disabled ๆใฏ onPressedChange ใ็บ็ซใใชใ", async () => {
const handlePressedChange = vi.fn();
const user = userEvent.setup();
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton disabled onPressedChange={handlePressedChange}>
Toggle
</ToolbarToggleButton>
</Toolbar>
);
await user.click(screen.getByRole("button"));
expect(handlePressedChange).not.toHaveBeenCalled();
});
it("disabled ๆใฏใใฉใผใซในๅฏพ่ฑกๅค๏ผdisabledๅฑๆงใง้ใใฉใผใซใน๏ผ", () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton disabled>Toggle</ToolbarToggleButton>
</Toolbar>
);
expect(screen.getByRole("button")).toBeDisabled();
});
});
});
describe("ToolbarSeparator", () => {
describe("ARIA ๅฑๆง", () => {
it('role="separator" ใ่จญๅฎใใใ', () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>Before</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton>After</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("separator")).toBeInTheDocument();
});
it('horizontal toolbar ๆใซ aria-orientation="vertical"', () => {
render(
<Toolbar aria-label="Test toolbar" orientation="horizontal">
<ToolbarButton>Before</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton>After</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("separator")).toHaveAttribute(
"aria-orientation",
"vertical"
);
});
it('vertical toolbar ๆใซ aria-orientation="horizontal"', () => {
render(
<Toolbar aria-label="Test toolbar" orientation="vertical">
<ToolbarButton>Before</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton>After</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("separator")).toHaveAttribute(
"aria-orientation",
"horizontal"
);
});
});
});
describe("ใขใฏใปใทใใชใใฃ", () => {
it("axe ใซใใ WCAG 2.1 AA ้ๅใใชใ", async () => {
const { container } = render(
<Toolbar aria-label="Text formatting">
<ToolbarToggleButton>Bold</ToolbarToggleButton>
<ToolbarToggleButton>Italic</ToolbarToggleButton>
<ToolbarSeparator />
<ToolbarButton>Copy</ToolbarButton>
<ToolbarButton>Paste</ToolbarButton>
</Toolbar>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
it("vertical toolbar ใงใ WCAG 2.1 AA ้ๅใใชใ", async () => {
const { container } = render(
<Toolbar aria-label="Actions" orientation="vertical">
<ToolbarButton>New</ToolbarButton>
<ToolbarButton>Open</ToolbarButton>
<ToolbarSeparator />
<ToolbarButton>Save</ToolbarButton>
</Toolbar>
);
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});
describe("HTML ๅฑๆง็ถๆฟ", () => {
it("className ใใณใณใใใซ้ฉ็จใใใ", () => {
render(
<Toolbar aria-label="Test toolbar" className="custom-toolbar">
<ToolbarButton>Button</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("toolbar")).toHaveClass("custom-toolbar");
});
it("ToolbarButton ใฎ className ใ้ฉ็จใใใ", () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton className="custom-button">Button</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("button")).toHaveClass("custom-button");
});
it("ToolbarToggleButton ใฎ className ใ้ฉ็จใใใ", () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarToggleButton className="custom-toggle">Toggle</ToolbarToggleButton>
</Toolbar>
);
expect(screen.getByRole("button")).toHaveClass("custom-toggle");
});
it("ToolbarSeparator ใฎ className ใ้ฉ็จใใใ", () => {
render(
<Toolbar aria-label="Test toolbar">
<ToolbarButton>Before</ToolbarButton>
<ToolbarSeparator className="custom-separator" />
<ToolbarButton>After</ToolbarButton>
</Toolbar>
);
expect(screen.getByRole("separator")).toHaveClass("custom-separator");
});
});
// Import React for the controlled component test
import React from "react";