diff --git a/apps/www/src/components/playground/index.ts b/apps/www/src/components/playground/index.ts index 62ec07833..5d32811a0 100644 --- a/apps/www/src/components/playground/index.ts +++ b/apps/www/src/components/playground/index.ts @@ -44,4 +44,5 @@ export * from './tabs-examples'; export * from './text-area-examples'; export * from './text-examples'; export * from './toast-examples'; +export * from './toolbar-examples'; export * from './tooltip-examples'; diff --git a/apps/www/src/components/playground/toolbar-examples.tsx b/apps/www/src/components/playground/toolbar-examples.tsx new file mode 100644 index 000000000..bbe0eb77a --- /dev/null +++ b/apps/www/src/components/playground/toolbar-examples.tsx @@ -0,0 +1,95 @@ +'use client'; + +import { + FontBoldIcon, + FontItalicIcon, + TextAlignCenterIcon, + TextAlignLeftIcon, + TextAlignRightIcon, + UnderlineIcon +} from '@radix-ui/react-icons'; +import { Button, Flex, IconButton, Text, Toolbar } from '@raystack/apsara'; +import PlaygroundLayout from './playground-layout'; + +export function ToolbarExamples() { + return ( + + + Default: + + Bold + Italic + + Left + Center + Right + + + Grouped with composition: + + } + > + 32px + + + + + } + > + + + + } + > + + + + } + > + + + + + + + } + > + + + + } + > + + + + } + > + + + + + + Disabled: + + Bold + Italic + + Left + + + + ); +} diff --git a/apps/www/src/content/docs/components/toolbar/demo.ts b/apps/www/src/content/docs/components/toolbar/demo.ts new file mode 100644 index 000000000..86db53cba --- /dev/null +++ b/apps/www/src/content/docs/components/toolbar/demo.ts @@ -0,0 +1,61 @@ +'use client'; + +export const preview = { + type: 'code', + code: ` + Bold + Italic + Underline + + Left + Center + Right +` +}; + +export const groupDemo = { + type: 'code', + code: ` + + Bold + Italic + Underline + + + + Left + Center + Right + +` +}; + +export const compositionDemo = { + type: 'code', + code: ` + }> + 32px + + + + }> + + + }> + + + + + Help +` +}; + +export const disabledDemo = { + type: 'code', + code: ` + Bold + Italic + + Left +` +}; diff --git a/apps/www/src/content/docs/components/toolbar/index.mdx b/apps/www/src/content/docs/components/toolbar/index.mdx new file mode 100644 index 000000000..256b86207 --- /dev/null +++ b/apps/www/src/content/docs/components/toolbar/index.mdx @@ -0,0 +1,95 @@ +--- +title: Toolbar +description: A container for grouping interactive controls with accessible keyboard navigation. +source: packages/raystack/components/toolbar +tag: new +--- + +import { preview, groupDemo, compositionDemo, disabledDemo } from "./demo.ts"; + + + +## Anatomy + +Import and assemble the component: + +```tsx +import { Toolbar } from '@raystack/apsara' + + + + + + + + + + + +``` + +## API Reference + +### Root + +Groups all toolbar controls. Manages keyboard navigation between items. + + + +### Button + +An interactive button within the toolbar. Supports the `render` prop to compose with other components like `Button` or `IconButton`. + + + +### Group + +Groups related toolbar items together. + + + +### Separator + +A visual divider between toolbar sections. + + + +### Link + +An anchor element for navigation within the toolbar. + + + +### Input + +A native input element with integrated keyboard navigation. + + + +## Examples + +### Grouped + +Use `Toolbar.Group` to group related buttons. + + + +### Composition + +Use the `render` prop on `Toolbar.Button` to compose with Apsara components like `Button` and `IconButton`. + + + +### Disabled + +Disable all toolbar items by setting `disabled` on the root. + + + +## Accessibility + +- Uses `role="toolbar"` for proper semantic grouping +- Arrow keys navigate between focusable items +- `Home` and `End` keys jump to first and last items +- Focus loops by default (configurable via `loopFocus`) +- Disabled items remain focusable for screen reader discoverability diff --git a/apps/www/src/content/docs/components/toolbar/props.ts b/apps/www/src/content/docs/components/toolbar/props.ts new file mode 100644 index 000000000..744642267 --- /dev/null +++ b/apps/www/src/content/docs/components/toolbar/props.ts @@ -0,0 +1,95 @@ +export interface ToolbarProps { + /** + * Whether keyboard navigation loops from the last item back to the first. + * @defaultValue true + */ + loopFocus?: boolean; + + /** + * The orientation of the toolbar layout. + * @defaultValue "horizontal" + */ + orientation?: 'horizontal' | 'vertical'; + + /** + * Whether the toolbar and all its items are disabled. + * @defaultValue false + */ + disabled?: boolean; + + /** Additional CSS class names. */ + className?: string; +} + +export interface ToolbarButtonProps { + /** + * Whether the button remains focusable when disabled. + * @defaultValue true + */ + focusableWhenDisabled?: boolean; + + /** + * Whether the button is disabled. + * @defaultValue false + */ + disabled?: boolean; + + /** + * Allows you to replace the component's HTML element with a different tag, or compose it with another component. + * + * @remarks `ReactElement | function` + */ + render?: + | React.ReactElement + | ((props: React.HTMLAttributes) => React.ReactElement); + + /** Additional CSS class names. */ + className?: string; +} + +export interface ToolbarGroupProps { + /** + * Whether the group and all its items are disabled. + * @defaultValue false + */ + disabled?: boolean; + + /** Additional CSS class names. */ + className?: string; +} + +export interface ToolbarSeparatorProps { + /** Additional CSS class names. */ + className?: string; +} + +export interface ToolbarLinkProps { + /** + * Allows you to replace the component's HTML element with a different tag, or compose it with another component. + * + * @remarks `ReactElement | function` + */ + render?: + | React.ReactElement + | ((props: React.HTMLAttributes) => React.ReactElement); + + /** Additional CSS class names. */ + className?: string; +} + +export interface ToolbarInputProps { + /** + * Whether the input remains focusable when disabled. + * @defaultValue true + */ + focusableWhenDisabled?: boolean; + + /** + * Whether the input is disabled. + * @defaultValue false + */ + disabled?: boolean; + + /** Additional CSS class names. */ + className?: string; +} diff --git a/packages/raystack/components/toolbar/__tests__/toolbar.test.tsx b/packages/raystack/components/toolbar/__tests__/toolbar.test.tsx new file mode 100644 index 000000000..67b48e807 --- /dev/null +++ b/packages/raystack/components/toolbar/__tests__/toolbar.test.tsx @@ -0,0 +1,196 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { Toolbar } from '../toolbar'; +import styles from '../toolbar.module.css'; + +describe('Toolbar', () => { + it('renders with children', () => { + render( + + Bold + + ); + expect(screen.getByRole('toolbar')).toBeInTheDocument(); + expect(screen.getByText('Bold')).toBeInTheDocument(); + }); + + it('applies custom className to root', () => { + render(content); + const toolbar = screen.getByRole('toolbar'); + expect(toolbar.className).toContain(styles.root); + expect(toolbar.className).toContain('custom'); + }); + + it('forwards ref to root', () => { + const ref = createRef(); + render(content); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + expect(ref.current).toBe(screen.getByRole('toolbar')); + }); + + describe('Button', () => { + it('renders as a button', () => { + render( + + Click + + ); + expect(screen.getByRole('button', { name: 'Click' })).toBeInTheDocument(); + }); + + it('handles click events', async () => { + const onClick = vi.fn(); + render( + + Click + + ); + fireEvent.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('applies custom className', () => { + render( + + Click + + ); + const button = screen.getByRole('button'); + expect(button.className).toContain(styles.button); + expect(button.className).toContain('custom'); + }); + + it('forwards ref', () => { + const ref = createRef(); + render( + + Click + + ); + expect(ref.current).toBeInstanceOf(HTMLButtonElement); + }); + }); + + describe('Group', () => { + it('renders children in a group', () => { + render( + + + A + B + + + ); + const group = screen.getByTestId('group'); + expect(group.className).toContain(styles.group); + expect(screen.getByText('A')).toBeInTheDocument(); + expect(screen.getByText('B')).toBeInTheDocument(); + }); + + it('forwards ref', () => { + const ref = createRef(); + render( + + + A + + + ); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + }); + + describe('Separator', () => { + it('renders a separator', () => { + render( + + + + ); + const sep = screen.getByTestId('sep'); + expect(sep).toBeInTheDocument(); + expect(sep.className).toContain(styles.separator); + }); + + it('forwards ref', () => { + const ref = createRef(); + render( + + + + ); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + }); + + describe('Link', () => { + it('renders a link', () => { + render( + + Example + + ); + const link = screen.getByRole('link', { name: 'Example' }); + expect(link).toBeInTheDocument(); + expect(link).toHaveAttribute('href', 'https://example.com'); + }); + + it('forwards ref', () => { + const ref = createRef(); + render( + + + Link + + + ); + expect(ref.current).toBeInstanceOf(HTMLAnchorElement); + }); + }); + + describe('Input', () => { + it('renders an input', () => { + render( + + + + ); + expect(screen.getByTestId('input')).toBeInTheDocument(); + }); + + it('forwards ref', () => { + const ref = createRef(); + render( + + + + ); + expect(ref.current).toBeInstanceOf(HTMLInputElement); + }); + }); + + describe('Keyboard navigation', () => { + it('navigates between buttons with arrow keys', async () => { + const user = userEvent.setup(); + render( + + First + Second + Third + + ); + + const first = screen.getByText('First'); + first.focus(); + expect(first).toHaveFocus(); + + await user.keyboard('{ArrowRight}'); + expect(screen.getByText('Second')).toHaveFocus(); + + await user.keyboard('{ArrowRight}'); + expect(screen.getByText('Third')).toHaveFocus(); + }); + }); +}); diff --git a/packages/raystack/components/toolbar/index.tsx b/packages/raystack/components/toolbar/index.tsx new file mode 100644 index 000000000..dc5abb129 --- /dev/null +++ b/packages/raystack/components/toolbar/index.tsx @@ -0,0 +1 @@ +export { Toolbar } from './toolbar'; diff --git a/packages/raystack/components/toolbar/toolbar.module.css b/packages/raystack/components/toolbar/toolbar.module.css new file mode 100644 index 000000000..f9c52d459 --- /dev/null +++ b/packages/raystack/components/toolbar/toolbar.module.css @@ -0,0 +1,50 @@ +.root { + display: flex; + align-items: center; + gap: var(--rs-space-1); + padding: var(--rs-space-1); + border: 0.5px solid var(--rs-color-border-base-primary); + border-radius: var(--rs-radius-2); + overflow: clip; +} + +.button { + cursor: pointer; + background: none; + border: none; + outline: none; + padding: var(--rs-space-1) var(--rs-space-2); + border-radius: var(--rs-radius-2); + font-family: var(--rs-font-body); + font-size: var(--rs-font-size-mini); + font-weight: var(--rs-font-weight-medium); + line-height: var(--rs-line-height-mini); + letter-spacing: var(--rs-letter-spacing-mini); + color: var(--rs-color-foreground-base-primary); +} + +.button:hover, +.button:focus-visible { + background-color: var(--rs-color-background-base-primary-hover); +} + +.button[data-disabled] { + pointer-events: none; + opacity: 0.5; +} + +.group { + display: flex; + align-items: center; +} + +.group > * { + border: none; +} + +.separator { + flex-shrink: 0; + width: 0.5px; + height: 16px; + background-color: var(--rs-color-border-base-primary); +} diff --git a/packages/raystack/components/toolbar/toolbar.tsx b/packages/raystack/components/toolbar/toolbar.tsx new file mode 100644 index 000000000..54be0e3fd --- /dev/null +++ b/packages/raystack/components/toolbar/toolbar.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { Toolbar as ToolbarPrimitive } from '@base-ui/react/toolbar'; +import { cx } from 'class-variance-authority'; +import { ElementRef, forwardRef } from 'react'; + +import styles from './toolbar.module.css'; + +const ToolbarRoot = forwardRef< + ElementRef, + ToolbarPrimitive.Root.Props +>(({ className, ...props }, ref) => ( + +)); +ToolbarRoot.displayName = 'Toolbar'; + +const ToolbarButton = forwardRef< + ElementRef, + ToolbarPrimitive.Button.Props +>(({ className, ...props }, ref) => ( + +)); +ToolbarButton.displayName = 'Toolbar.Button'; + +const ToolbarGroup = forwardRef< + ElementRef, + ToolbarPrimitive.Group.Props +>(({ className, ...props }, ref) => ( + +)); +ToolbarGroup.displayName = 'Toolbar.Group'; + +const ToolbarSeparator = forwardRef< + ElementRef, + ToolbarPrimitive.Separator.Props +>(({ className, ...props }, ref) => ( + +)); +ToolbarSeparator.displayName = 'Toolbar.Separator'; + +const ToolbarLink = forwardRef< + ElementRef, + ToolbarPrimitive.Link.Props +>(({ className, ...props }, ref) => ( + +)); +ToolbarLink.displayName = 'Toolbar.Link'; + +const ToolbarInput = forwardRef< + ElementRef, + ToolbarPrimitive.Input.Props +>(({ className, ...props }, ref) => ( + +)); +ToolbarInput.displayName = 'Toolbar.Input'; + +export const Toolbar = Object.assign(ToolbarRoot, { + Button: ToolbarButton, + Group: ToolbarGroup, + Separator: ToolbarSeparator, + Link: ToolbarLink, + Input: ToolbarInput +}); diff --git a/packages/raystack/index.tsx b/packages/raystack/index.tsx index 37885b603..edf337c04 100644 --- a/packages/raystack/index.tsx +++ b/packages/raystack/index.tsx @@ -68,4 +68,5 @@ export { useTheme } from './components/theme-provider'; export { Toast, toastManager } from './components/toast'; +export { Toolbar } from './components/toolbar'; export { Tooltip } from './components/tooltip';