Skip to main content
Glama
button.stories.tsx28.6 kB
import type { Meta, StoryObj } from '@storybook/react'; import { expect, userEvent, within } from '@storybook/test'; import { ArrowRightIcon, DownloadIcon, HeartIcon, PlayIcon, PlusIcon, SaveIcon, SettingsIcon, TrashIcon, } from 'lucide-react'; import { Button, ButtonColor, ButtonSize, ButtonTextAlign, ButtonVariant, } from './Button'; /** * Button Component Stories * * The Button component is a fundamental interactive element that triggers actions * when clicked or activated. It supports multiple variants, sizes, colors, and * accessibility features to cover various use cases in your application. * * ## Key Features * - **Accessibility**: Full ARIA support with keyboard navigation and screen reader compatibility * - **Variants**: Multiple visual styles (default, outline, link, hoverable) * - **Sizes**: From small icons to extra-large call-to-action buttons * - **States**: Loading, active, disabled, and focus states * - **Icons**: Support for left and right icon positioning * - **Responsive**: Adaptive sizing for different screen sizes * * ## When to Use * - Primary actions (save, submit, create) * - Secondary actions (cancel, edit, delete) * - Navigation (back, next, learn more) * - Toggle states (like, favorite, bookmark) * - Icon-only actions (settings, menu, close) */ const meta = { title: 'Components/Button', component: Button, parameters: { docs: { description: { component: ` A versatile button component that handles user interactions with full accessibility support. ### Accessibility Features: - **Keyboard Navigation**: Full support for Tab, Enter, and Space key interactions - **Screen Readers**: Proper ARIA labels, descriptions, and state announcements - **Focus Management**: Visible focus indicators with customizable ring colors - **Loading States**: Announces loading status to assistive technologies - **Icon Buttons**: Accessible labels for icon-only buttons ### Visual Variants: - **Default**: Solid background with hover effects - **Outline**: Border with transparent background - **Link**: Text-only with underline on hover - **Hoverable**: Subtle background on hover - **Invisible Link**: Link without underline ### Use Cases: - Form submissions and data actions - Navigation and routing - Toggles and state changes - Modal triggers and confirmations - Toolbar and menu actions `, }, }, a11y: { config: { rules: [ { id: 'color-contrast', enabled: true, }, { id: 'focus-order-semantics', enabled: true, }, ], }, }, }, tags: ['autodocs'], argTypes: { children: { description: 'The visible text content of the button', control: 'text', }, label: { description: 'Accessible label for screen readers (required)', control: 'text', }, variant: { description: 'Visual style variant of the button', control: 'select', options: Object.values(ButtonVariant), }, size: { description: 'Size variant affecting padding and text size', control: 'select', options: Object.values(ButtonSize), }, color: { description: 'Color theme that determines text and focus ring colors', control: 'select', options: Object.values(ButtonColor), }, textAlign: { description: 'Text alignment within the button', control: 'select', options: Object.values(ButtonTextAlign), }, isLoading: { description: 'Shows loading spinner and disables interaction', control: 'boolean', }, isActive: { description: 'Marks button as active/current (for navigation)', control: 'boolean', }, disabled: { description: 'Disables the button and prevents interaction', control: 'boolean', }, isFullWidth: { description: 'Makes button span full container width', control: 'boolean', }, Icon: { description: 'Icon component displayed on the left side', control: 'select', options: ['None', 'PlayIcon', 'SaveIcon', 'DownloadIcon', 'PlusIcon'], mapping: { None: undefined, PlayIcon, SaveIcon, DownloadIcon, PlusIcon, }, }, IconRight: { description: 'Icon component displayed on the right side', control: 'select', options: ['None', 'ArrowRightIcon', 'DownloadIcon', 'HeartIcon'], mapping: { None: undefined, ArrowRightIcon, DownloadIcon, HeartIcon, }, }, 'aria-describedby': { description: 'ID of element providing additional description', control: 'text', }, 'aria-expanded': { description: 'Indicates if controlled element is expanded', control: 'boolean', }, 'aria-haspopup': { description: 'Indicates button has popup/menu', control: 'select', options: [false, true, 'menu', 'listbox', 'tree', 'grid', 'dialog'], }, type: { description: 'HTML button type attribute', control: 'select', options: ['button', 'submit', 'reset'], }, }, } satisfies Meta<typeof Button>; export default meta; type Story = StoryObj<typeof Button>; /** * ## Basic Examples * * These stories demonstrate the core functionality and appearance of the Button component * in its most common configurations. */ /** * ### Default State * * The basic button with default styling. This is the most common button variant * used for primary actions throughout your application. */ export const Default: Story = { args: { children: 'Click me', label: 'Click me', variant: ButtonVariant.DEFAULT, size: ButtonSize.MD, color: ButtonColor.PRIMARY, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const button = canvas.getByRole('button', { name: /click me/i }); // Test initial state await expect(button).toBeInTheDocument(); await expect(button).not.toBeDisabled(); await expect(button).toHaveAccessibleName('Click me'); // Test interaction await userEvent.click(button); await expect(button).toHaveFocus(); // Test keyboard navigation await userEvent.keyboard('{Tab}'); }, }; /** * ### All Variants * * Showcase of all available button variants to help choose the right style * for different use cases and hierarchies. */ export const AllVariants: Story = { render: () => ( <div className="flex flex-wrap items-center gap-4"> <Button variant={ButtonVariant.DEFAULT} label="Default button" color={ButtonColor.PRIMARY} > Default </Button> <Button variant={ButtonVariant.OUTLINE} label="Outline button" color={ButtonColor.PRIMARY} > Outline </Button> <Button variant={ButtonVariant.LINK} label="Link button" color={ButtonColor.PRIMARY} > Link </Button> <Button variant={ButtonVariant.HOVERABLE} label="Hoverable button" color={ButtonColor.PRIMARY} > Hoverable </Button> <Button variant={ButtonVariant.INVISIBLE_LINK} label="Invisible link button" color={ButtonColor.PRIMARY} > Invisible Link </Button> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Find buttons (role="button") const buttons = canvas.getAllByRole('button'); // Find links (role="link") - for LINK and INVISIBLE_LINK variants const links = canvas.getAllByRole('link'); // Total should be 5 elements (3 buttons + 2 links) const totalElements = buttons.length + links.length; await expect(totalElements).toBe(5); // Test each element is accessible [...buttons, ...links].forEach(async (element) => { await expect(element).toBeInTheDocument(); await expect(element).not.toBeDisabled(); await expect(element).toHaveAccessibleName(); }); // Test that each variant renders correctly const defaultButton = canvas.getByRole('button', { name: /default/i }); const outlineButton = canvas.getByRole('button', { name: /outline/i }); const hoverableButton = canvas.getByRole('button', { name: /hoverable/i }); const linkButton = canvas.getByRole('link', { name: /^link$/i }); const invisibleLinkButton = canvas.getByRole('link', { name: /^invisible link$/i, }); // Verify all elements are distinct and present await expect(defaultButton).toBeInTheDocument(); await expect(outlineButton).toBeInTheDocument(); await expect(hoverableButton).toBeInTheDocument(); await expect(linkButton).toBeInTheDocument(); await expect(invisibleLinkButton).toBeInTheDocument(); }, }; /** * ### Size Variations * * Different button sizes for various contexts - from compact icon buttons * to large call-to-action buttons. */ export const SizeVariations: Story = { render: () => ( <div className="flex flex-wrap items-center gap-4"> <Button size={ButtonSize.ICON_LG} label="Extra small button" color={ButtonColor.PRIMARY} > LG </Button> <Button size={ButtonSize.SM} label="Small button" color={ButtonColor.PRIMARY} > Small </Button> <Button size={ButtonSize.MD} label="Medium button" color={ButtonColor.PRIMARY} > Medium </Button> <Button size={ButtonSize.LG} label="Large button" color={ButtonColor.PRIMARY} > Large </Button> <Button size={ButtonSize.XL} label="Extra large button" color={ButtonColor.PRIMARY} > XL </Button> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const buttons = canvas.getAllByRole('button'); await expect(buttons).toHaveLength(5); for (let i = 0; i < buttons.length; i++) { const button = buttons[i]; await expect(button).toBeInTheDocument(); await expect(button).toHaveAccessibleName(); } }, }; /** * ### Color Themes * * Available color themes that can be applied to buttons for different * semantic meanings and visual hierarchies. */ export const ColorThemes: Story = { render: () => ( <div className="grid max-w-2xl grid-cols-2 gap-4"> {Object.values(ButtonColor).map((color) => ( <div key={color} className="space-y-2"> <h4 className="font-medium text-sm capitalize"> {color.replace('_', ' ')} </h4> <div className="flex gap-2"> <Button color={color} variant={ButtonVariant.DEFAULT} label={`${color} default button`} size={ButtonSize.SM} > Default </Button> <Button color={color} variant={ButtonVariant.OUTLINE} label={`${color} outline button`} size={ButtonSize.SM} > Outline </Button> </div> </div> ))} </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const buttons = canvas.getAllByRole('button'); // Should have 2 buttons per color (default + outline) const expectedCount = Object.values(ButtonColor).length * 2; await expect(buttons).toHaveLength(expectedCount); // Test color accessibility for (const button of buttons) { await expect(button).toBeInTheDocument(); await expect(button).toHaveAccessibleName(); } }, }; /** * ## Interactive States * * These stories demonstrate button behavior in different states and interactive scenarios. */ /** * ### Loading State * * Shows the loading spinner and prevents interaction while an async operation * is in progress. The button remains accessible to screen readers. */ export const LoadingState: Story = { args: { children: 'Save Changes', label: 'Save changes', isLoading: true, variant: ButtonVariant.DEFAULT, color: ButtonColor.PRIMARY, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const button = canvas.getByRole('button', { name: /save changes/i }); // Test loading state await expect(button).toBeInTheDocument(); await expect(button).toBeDisabled(); await expect(button).toHaveAttribute('aria-busy', 'true'); // Loading spinner should be present const loader = canvas.getByTestId('loader'); await expect(loader).toBeInTheDocument(); // Button should not respond to clicks await expect(button).toBeDisabled(); await expect(button).not.toHaveFocus(); }, }; /** * ### Disabled State * * Button in disabled state - visually dimmed and not interactive. * Properly communicated to assistive technologies. */ export const DisabledState: Story = { args: { children: 'Disabled', label: 'Disabled button', disabled: true, variant: ButtonVariant.DEFAULT, color: ButtonColor.PRIMARY, }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const button = canvas.getByRole('button', { name: /disabled/i }); await expect(button).toBeDisabled(); await expect(button).toHaveAttribute('disabled'); await expect(button).not.toHaveFocus(); }, }; /** * ### Active State * * Button in active/pressed state, typically used for navigation * or toggle scenarios to show current selection. */ export const ActiveState: Story = { args: { children: 'Active', label: 'Active button', isActive: true, variant: ButtonVariant.DEFAULT, color: ButtonColor.PRIMARY, 'aria-current': 'true', 'aria-label': 'Active button', }, play: async ({ canvasElement }) => { const canvas = within(canvasElement); const button = canvas.getByRole('button', { name: /active/i }); await expect(button).toBeInTheDocument(); await expect(button).not.toBeDisabled(); await expect(button).toHaveAttribute('aria-current', 'true'); await expect(button).toHaveAttribute('aria-label', 'Active button'); // Active button should still be interactive await userEvent.click(button); }, }; /** * ## Icon Buttons * * Buttons with icons for enhanced visual communication and compact interfaces. */ /** * ### With Left Icons * * Common pattern of icon + text for clear action indication. */ export const WithLeftIcons: Story = { render: () => ( <div className="flex flex-wrap gap-4"> <Button Icon={PlayIcon} label="Play video" color={ButtonColor.PRIMARY}> Play </Button> <Button Icon={SaveIcon} label="Save document" color={ButtonColor.SUCCESS}> Save </Button> <Button Icon={DownloadIcon} label="Download file" variant={ButtonVariant.OUTLINE} color={ButtonColor.PRIMARY} > Download </Button> <Button Icon={PlusIcon} label="Add new item" color={ButtonColor.PRIMARY}> Add New </Button> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const buttons = canvas.getAllByRole('button'); await expect(buttons).toHaveLength(4); for (const button of buttons) { await expect(button).toHaveAccessibleName(); // Each button should have an icon const svgIcon = button.querySelector('svg'); await expect(svgIcon).toBeInTheDocument(); } }, }; /** * ### With Right Icons * * Icons on the right side, typically used for directional actions * or external links. */ export const WithRightIcons: Story = { render: () => ( <div className="flex flex-wrap gap-4"> <Button IconRight={ArrowRightIcon} label="Continue to next step" color={ButtonColor.PRIMARY} > Continue </Button> <Button IconRight={DownloadIcon} label="Export data" variant={ButtonVariant.OUTLINE} color={ButtonColor.PRIMARY} > Export </Button> <Button IconRight={HeartIcon} label="Add to favorites" variant={ButtonVariant.HOVERABLE} color={ButtonColor.ERROR} > Like </Button> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const buttons = canvas.getAllByRole('button'); await expect(buttons).toHaveLength(3); for (const button of buttons) { await expect(button).toHaveAccessibleName(); // Each button should have a right-side icon const svgIcon = button.querySelector('svg'); await expect(svgIcon).toBeInTheDocument(); } }, }; /** * ### Icon Only Buttons * * Compact buttons with only icons - requires proper labeling for accessibility. */ export const IconOnlyButtons: Story = { render: () => ( <div className="flex flex-wrap gap-4"> <Button Icon={SettingsIcon} label="" aria-label="Open settings" variant={ButtonVariant.OUTLINE} size={ButtonSize.ICON_LG} color={ButtonColor.NEUTRAL} /> <Button Icon={HeartIcon} label="" aria-label="Like this item" variant={ButtonVariant.OUTLINE} size={ButtonSize.ICON_MD} color={ButtonColor.ERROR} /> <Button Icon={DownloadIcon} label="" aria-label="Download file" variant={ButtonVariant.OUTLINE} size={ButtonSize.ICON_SM} color={ButtonColor.PRIMARY} /> <Button Icon={TrashIcon} label="" aria-label="Delete item" variant={ButtonVariant.OUTLINE} size={ButtonSize.ICON_XL} color={ButtonColor.ERROR} /> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const buttons = canvas.getAllByRole('button'); await expect(buttons).toHaveLength(4); for (const button of buttons) { await expect(button).toHaveAccessibleName(); const textContent = button.textContent?.trim(); await expect(textContent).toBeFalsy(); await expect(button).toHaveAttribute('aria-label'); const svgIcon = button.querySelector('svg'); await expect(svgIcon).toBeInTheDocument(); } const firstButton = buttons[0]; await userEvent.click(firstButton); await expect(firstButton).toHaveFocus(); await userEvent.keyboard('{Tab}'); await expect(buttons[1]).toHaveFocus(); }, }; /** * ## Layout and Responsive * * Stories demonstrating button layout options and responsive behavior. */ /** * ### Full Width Button * * Button that spans the full width of its container, commonly used * in forms and mobile interfaces. */ export const FullWidthButton: Story = { args: { children: 'Submit Form', label: 'Submit form', isFullWidth: true, size: ButtonSize.LG, color: ButtonColor.SUCCESS, variant: ButtonVariant.DEFAULT, type: 'submit', }, decorators: [ (Story) => ( <div className="mx-auto max-w-md rounded-lg border border-gray-200 p-4"> <div className="mb-4"> <label className="mb-2 block font-medium text-sm">Email</label> <input type="email" className="w-full rounded border border-gray-300 p-2" placeholder="Enter your email" /> </div> <Story /> </div> ), ], play: async ({ canvasElement }) => { const canvas = within(canvasElement); const button = canvas.getByRole('button', { name: /submit form/i }); await expect(button).toBeInTheDocument(); await expect(button).not.toBeDisabled(); await expect(button).toHaveProperty('type', 'submit'); const parentContainer = button.parentElement; if (parentContainer) { const buttonRect = button.getBoundingClientRect(); const containerRect = parentContainer.getBoundingClientRect(); const widthRatio = buttonRect.width / containerRect.width; expect(widthRatio).toBeGreaterThan(0.9); } }, }; /** * ### Text Alignment * * Different text alignment options within buttons, useful for * full-width buttons or specific design requirements. */ export const TextAlignment: Story = { render: () => ( <div className="max-w-md space-y-4"> <Button isFullWidth textAlign={ButtonTextAlign.LEFT} label="Left aligned button" color={ButtonColor.PRIMARY} > Left Aligned </Button> <Button isFullWidth textAlign={ButtonTextAlign.CENTER} label="Center aligned button" color={ButtonColor.PRIMARY} > Center Aligned </Button> <Button isFullWidth textAlign={ButtonTextAlign.RIGHT} label="Right aligned button" color={ButtonColor.PRIMARY} > Right Aligned </Button> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const buttons = canvas.getAllByRole('button'); await expect(buttons).toHaveLength(3); for (const button of buttons) { await expect(button).toBeInTheDocument(); await expect(button).toHaveAccessibleName(); } }, }; /** * ## Accessibility Testing * * Stories specifically designed to test and demonstrate accessibility features. */ /** * ### Keyboard Navigation * * Demonstrates proper keyboard navigation and focus management * across multiple buttons. */ export const KeyboardNavigation: Story = { render: () => ( <div className="space-y-4"> <div className="mb-4 text-gray-600 text-sm"> Use Tab to navigate between buttons, Enter or Space to activate them. </div> <div className="flex flex-wrap gap-4"> <Button label="First button in sequence" color={ButtonColor.PRIMARY}> First </Button> <Button label="Second button in sequence" variant={ButtonVariant.OUTLINE} color={ButtonColor.PRIMARY} > Second </Button> <Button label="Fourth button (disabled)" disabled color={ButtonColor.PRIMARY} > Disabled </Button> <Button label="Last button in sequence" color={ButtonColor.SUCCESS}> Last </Button> </div> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); const buttons = canvas.getAllByRole('button'); // Test initial focus const firstButton = buttons[0]; await userEvent.click(firstButton); await expect(firstButton).toHaveFocus(); // sleep for a moment to simulate user pause await new Promise((r) => setTimeout(r, 100)); // Test tab navigation (should skip disabled button) await userEvent.keyboard('{Tab}'); await expect(buttons[1]).toHaveFocus(); // sleep for a moment to simulate user pause await new Promise((r) => setTimeout(r, 100)); await new Promise((r) => setTimeout(r, 100)); await userEvent.keyboard('{Tab}'); await expect(buttons[3]).toHaveFocus(); await userEvent.keyboard('{Enter}'); await userEvent.keyboard(' '); }, }; /** * ### ARIA Attributes * * Demonstrates proper ARIA attribute usage for complex button scenarios * like dropdowns, toggles, and expanded states. */ export const ARIAAttributes: Story = { render: () => ( <div className="space-y-6"> <div> <h3 className="mb-2 font-medium text-sm">Dropdown Button</h3> <Button label="Open menu options" color={ButtonColor.PRIMARY} aria-haspopup="menu" aria-expanded={false} IconRight={ArrowRightIcon} > Menu </Button> </div> <div> <h3 className="mb-2 font-medium text-sm">Toggle Button</h3> <Button label="Toggle favorite status" variant={ButtonVariant.OUTLINE} color={ButtonColor.ERROR} aria-pressed={false} Icon={HeartIcon} > Favorite </Button> </div> <div> <h3 className="mb-2 font-medium text-sm">Button with Description</h3> <div> <Button label="Delete selected items" color={ButtonColor.ERROR} aria-describedby="delete-help" Icon={TrashIcon} > Delete </Button> <div id="delete-help" className="mt-1 text-gray-500 text-xs"> This action cannot be undone </div> </div> </div> </div> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Test dropdown button const menuButton = canvas.getByRole('button', { name: /menu/i, }); await expect(menuButton).toHaveAttribute('aria-haspopup', 'menu'); await expect(menuButton).toHaveAttribute('aria-expanded', 'false'); // Test toggle button const toggleButton = canvas.getByRole('button', { name: /favorite/i, }); await expect(toggleButton).toHaveAttribute('aria-pressed', 'false'); // Test described button const deleteButton = canvas.getByRole('button', { name: /delete/i, }); await expect(deleteButton).toHaveAttribute( 'aria-describedby', 'delete-help' ); // Test that description is properly associated const description = canvasElement.querySelector('#delete-help'); await expect(description).toBeInTheDocument(); }, }; /** * ## Form Integration * * Stories showing button integration within forms and different submit scenarios. */ /** * ### Form Buttons * * Different button types within a form context - submit, reset, and regular buttons. */ export const FormButtons: Story = { render: () => ( <form className="max-w-md space-y-4 rounded-lg border border-gray-200 p-4" onSubmit={(e) => { e.preventDefault(); alert('Form submitted'); }} onReset={(e) => { e.preventDefault(); alert('Form reset'); }} > <div> <label htmlFor="name" className="mb-2 block font-medium text-sm"> Name </label> <input id="name" type="text" className="w-full rounded border border-gray-300 p-2" placeholder="Enter your name" /> </div> <div> <label htmlFor="email" className="mb-2 block font-medium text-sm"> Email </label> <input id="email" type="email" className="w-full rounded border border-gray-300 p-2" placeholder="Enter your email" /> </div> <div className="flex gap-4 pt-4"> <Button type="submit" label="Submit form" color={ButtonColor.SUCCESS} Icon={SaveIcon} > Submit </Button> <Button type="reset" label="Reset form" variant={ButtonVariant.OUTLINE} color={ButtonColor.NEUTRAL} > Reset </Button> <Button type="button" label="Cancel form" variant={ButtonVariant.OUTLINE} color={ButtonColor.DESTRUCTIVE} > Cancel </Button> </div> </form> ), play: async ({ canvasElement }) => { const canvas = within(canvasElement); // Test form buttons const submitButton = canvas.getByRole('button', { name: /submit/i }); const resetButton = canvas.getByRole('button', { name: /reset/i }); const cancelButton = canvas.getByRole('button', { name: /cancel/i }); await expect(submitButton).toHaveAttribute('type', 'submit'); await expect(resetButton).toHaveAttribute('type', 'reset'); await expect(cancelButton).toHaveAttribute('type', 'button'); // Test form interaction const nameInput = canvas.getByLabelText(/name/i); const emailInput = canvas.getByLabelText(/email/i); await userEvent.type(nameInput, 'John Doe'); await userEvent.type(emailInput, 'john@example.com'); // Test reset functionality await userEvent.click(resetButton); // Note: In a real form, this would reset the inputs // Test submit button focus and interaction await userEvent.click(submitButton); }, };

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/aymericzip/intlayer'

If you have feedback or need assistance with the MCP directory API, please join our Discord server