From 4c0f27ada4b5c3878e481afcd8a2b8977f950596 Mon Sep 17 00:00:00 2001 From: Nkeiru Lois Date: Thu, 30 Jul 2026 12:10:36 +0100 Subject: [PATCH] feat: add accessible themeable Button component to design system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Variants: primary, secondary, danger, ghost - Sizes: sm, md, lg - States: default, hover, focus, disabled, loading (spinner + aria-busy) - Fully keyboard accessible (Enter/Space) - Focus ring via :focus-visible (invisible to mouse users) - Themed via CSS custom properties — flips with [data-theme='light'] - Storybook stories for all variants, sizes and states - 24 unit tests — all passing Closes #969 --- frontend/src/components/ui/Button.css | 132 +++++++++++++++++ frontend/src/components/ui/Button.jsx | 67 +++++++++ frontend/src/components/ui/Button.test.jsx | 156 +++++++++++++++++++++ frontend/src/components/ui/index.js | 1 + frontend/src/stories/Button.stories.jsx | 107 ++++++++++++++ 5 files changed, 463 insertions(+) create mode 100644 frontend/src/components/ui/Button.css create mode 100644 frontend/src/components/ui/Button.jsx create mode 100644 frontend/src/components/ui/Button.test.jsx create mode 100644 frontend/src/stories/Button.stories.jsx diff --git a/frontend/src/components/ui/Button.css b/frontend/src/components/ui/Button.css new file mode 100644 index 00000000..e360fb48 --- /dev/null +++ b/frontend/src/components/ui/Button.css @@ -0,0 +1,132 @@ +/* Button — design-system styles. Themed entirely through CSS custom properties, + so the component flips with [data-theme='light'] without any JS. */ + +.ds-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--ds-space-2, 0.5rem); + padding: var(--ds-space-2, 0.5rem) var(--ds-space-4, 1rem); + border: 1px solid transparent; + border-radius: var(--ds-radius-md, 10px); + font: inherit; + font-size: var(--ds-font-size-md, 0.9375rem); + font-weight: 600; + white-space: nowrap; + cursor: pointer; + text-decoration: none; + position: relative; + transition: + color var(--ds-duration-fast, 120ms) var(--ds-easing, ease), + background var(--ds-duration-fast, 120ms) var(--ds-easing, ease), + border-color var(--ds-duration-fast, 120ms) var(--ds-easing, ease), + opacity var(--ds-duration-fast, 120ms) var(--ds-easing, ease); +} + +/* — sizes — */ +.ds-btn[data-size='sm'] { + padding: var(--ds-space-1, 0.25rem) var(--ds-space-3, 0.75rem); + font-size: var(--ds-font-size-sm, 0.8125rem); + border-radius: var(--ds-radius-sm, 6px); +} + +.ds-btn[data-size='lg'] { + padding: var(--ds-space-3, 0.75rem) var(--ds-space-5, 1.5rem); + font-size: 1.0625rem; + border-radius: var(--ds-radius-lg, 14px); +} + +/* — primary variant — */ +.ds-btn--primary { + background: var(--accent, #4c8dff); + color: #fff; + border-color: var(--accent, #4c8dff); +} + +.ds-btn--primary:hover:not(:disabled) { + background: var(--accent-hover, #3a7ae8); + border-color: var(--accent-hover, #3a7ae8); +} + +/* — secondary variant — */ +.ds-btn--secondary { + background: transparent; + color: var(--accent, #4c8dff); + border-color: var(--accent, #4c8dff); +} + +.ds-btn--secondary:hover:not(:disabled) { + background: var(--accent-soft, rgba(76, 141, 255, 0.16)); +} + +/* — danger variant — */ +.ds-btn--danger { + background: var(--danger, #e05252); + color: #fff; + border-color: var(--danger, #e05252); +} + +.ds-btn--danger:hover:not(:disabled) { + background: var(--danger-hover, #c94444); + border-color: var(--danger-hover, #c94444); +} + +/* — ghost variant — */ +.ds-btn--ghost { + background: transparent; + color: var(--text-muted, #a1b1c7); + border-color: transparent; +} + +.ds-btn--ghost:hover:not(:disabled) { + background: var(--accent-soft, rgba(76, 141, 255, 0.16)); + color: var(--text, #edf4ff); +} + +/* — disabled state — */ +.ds-btn:disabled, +.ds-btn[aria-disabled='true'] { + opacity: 0.45; + cursor: not-allowed; + pointer-events: none; +} + +/* — loading state — */ +.ds-btn--loading { + cursor: wait; + pointer-events: none; +} + +.ds-btn__spinner { + display: inline-block; + width: 1em; + height: 1em; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + animation: ds-btn-spin 0.6s linear infinite; + flex-shrink: 0; +} + +@keyframes ds-btn-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .ds-btn__spinner { + animation-duration: 0ms; + } +} + +/* — focus ring — */ +.ds-btn:focus-visible { + outline: 2px solid var(--accent, #4c8dff); + outline-offset: 2px; +} + +/* — full width — */ +.ds-btn--full { + width: 100%; +} \ No newline at end of file diff --git a/frontend/src/components/ui/Button.jsx b/frontend/src/components/ui/Button.jsx new file mode 100644 index 00000000..d58efc35 --- /dev/null +++ b/frontend/src/components/ui/Button.jsx @@ -0,0 +1,67 @@ +/** + * Button — shared design-system component. + * + * Variants: primary | secondary | danger | ghost + * Sizes: sm | md | lg + * States: default | hover | focus | disabled | loading + * + * Accessible: + * - Uses a native + * + * + */ + +import './tokens.css'; +import './Button.css'; + +export default function Button({ + variant = 'primary', + size = 'md', + loading = false, + disabled = false, + fullWidth = false, + type = 'button', + className = '', + children, + onClick, + ...rest +}) { + const isDisabled = disabled || loading; + + const classes = [ + 'ds-btn', + `ds-btn--${variant}`, + loading ? 'ds-btn--loading' : '', + fullWidth ? 'ds-btn--full' : '', + className, + ] + .filter(Boolean) + .join(' '); + + return ( + + ); +} diff --git a/frontend/src/components/ui/Button.test.jsx b/frontend/src/components/ui/Button.test.jsx new file mode 100644 index 00000000..f6d1c8f7 --- /dev/null +++ b/frontend/src/components/ui/Button.test.jsx @@ -0,0 +1,156 @@ +// Unit tests for the design-system Button component (issue #969). +// Covers all variants, sizes, states (disabled, loading) and accessibility. + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, it, expect, vi } from 'vitest'; +import Button from './Button.jsx'; + +describe('Button', () => { + // — rendering — + it('renders children correctly', () => { + render(); + expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument(); + }); + + it('renders with default type="button"', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('type', 'button'); + }); + + it('renders with type="submit" when specified', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('type', 'submit'); + }); + + // — variants — + it('applies primary variant class by default', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('ds-btn--primary'); + }); + + it('applies secondary variant class', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('ds-btn--secondary'); + }); + + it('applies danger variant class', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('ds-btn--danger'); + }); + + it('applies ghost variant class', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('ds-btn--ghost'); + }); + + // — sizes — + it('applies md size by default', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('data-size', 'md'); + }); + + it('applies sm size', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('data-size', 'sm'); + }); + + it('applies lg size', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('data-size', 'lg'); + }); + + // — disabled state — + it('is disabled when disabled prop is true', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('sets aria-disabled when disabled', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('aria-disabled', 'true'); + }); + + it('does not call onClick when disabled', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render(); + await user.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + // — loading state — + it('shows spinner when loading', () => { + render(); + expect(screen.getByRole('button').querySelector('.ds-btn__spinner')).toBeInTheDocument(); + }); + + it('sets aria-busy when loading', () => { + render(); + expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true'); + }); + + it('is disabled when loading', () => { + render(); + expect(screen.getByRole('button')).toBeDisabled(); + }); + + it('does not call onClick when loading', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render(); + await user.click(screen.getByRole('button')); + expect(onClick).not.toHaveBeenCalled(); + }); + + it('applies loading class when loading', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('ds-btn--loading'); + }); + + // — click handler — + it('calls onClick when clicked', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render(); + await user.click(screen.getByRole('button')); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + // — fullWidth — + it('applies full width class when fullWidth is true', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('ds-btn--full'); + }); + + // — custom className — + it('merges custom className', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('ds-btn', 'my-custom'); + }); + + // — keyboard accessibility — + it('is keyboard accessible with Enter key', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render(); + screen.getByRole('button').focus(); + await user.keyboard('{Enter}'); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('is keyboard accessible with Space key', async () => { + const user = userEvent.setup(); + const onClick = vi.fn(); + render(); + screen.getByRole('button').focus(); + await user.keyboard(' '); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + // — base class — + it('always has the ds-btn base class', () => { + render(); + expect(screen.getByRole('button')).toHaveClass('ds-btn'); + }); +}); diff --git a/frontend/src/components/ui/index.js b/frontend/src/components/ui/index.js index 0906793a..3c335bca 100644 --- a/frontend/src/components/ui/index.js +++ b/frontend/src/components/ui/index.js @@ -18,3 +18,4 @@ export { DEFAULT_PAGE_SIZE_OPTIONS, } from './Pagination.jsx'; export { Tooltip, Popover, PLACEMENTS } from './Tooltip.jsx'; +export { default as Button } from './Button.jsx'; \ No newline at end of file diff --git a/frontend/src/stories/Button.stories.jsx b/frontend/src/stories/Button.stories.jsx new file mode 100644 index 00000000..6ac2a336 --- /dev/null +++ b/frontend/src/stories/Button.stories.jsx @@ -0,0 +1,107 @@ +import Button from '../components/ui/Button.jsx'; + +export default { + title: 'Design System/Button', + component: Button, + tags: ['autodocs'], + argTypes: { + variant: { + control: 'select', + options: ['primary', 'secondary', 'danger', 'ghost'], + description: 'Visual style of the button', + }, + size: { + control: 'select', + options: ['sm', 'md', 'lg'], + description: 'Size of the button', + }, + loading: { + control: 'boolean', + description: 'Shows a spinner and disables interaction', + }, + disabled: { + control: 'boolean', + description: 'Disables the button', + }, + fullWidth: { + control: 'boolean', + description: 'Stretches the button to fill its container', + }, + children: { + control: 'text', + description: 'Button label', + }, + }, +}; + +// — variants — +export const Primary = { + args: { variant: 'primary', children: 'Primary' }, +}; + +export const Secondary = { + args: { variant: 'secondary', children: 'Secondary' }, +}; + +export const Danger = { + args: { variant: 'danger', children: 'Danger' }, +}; + +export const Ghost = { + args: { variant: 'ghost', children: 'Ghost' }, +}; + +// — sizes — +export const Small = { + args: { variant: 'primary', size: 'sm', children: 'Small' }, +}; + +export const Medium = { + args: { variant: 'primary', size: 'md', children: 'Medium' }, +}; + +export const Large = { + args: { variant: 'primary', size: 'lg', children: 'Large' }, +}; + +// — states — +export const Disabled = { + args: { variant: 'primary', disabled: true, children: 'Disabled' }, +}; + +export const Loading = { + args: { variant: 'primary', loading: true, children: 'Saving…' }, +}; + +export const FullWidth = { + args: { variant: 'primary', fullWidth: true, children: 'Full Width' }, +}; + +// — all variants at once — +export const AllVariants = { + render: () => ( +
+ + + + +
+ ), +}; + +// — all sizes at once — +export const AllSizes = { + render: () => ( +
+ + + +
+ ), +}; + +// — dark mode — +export const DarkMode = { + args: { variant: 'primary', children: 'Dark Mode' }, + parameters: { backgrounds: { default: 'dark' } }, +}; \ No newline at end of file