Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions frontend/src/components/ui/Button.css
Original file line number Diff line number Diff line change
@@ -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%;
}
67 changes: 67 additions & 0 deletions frontend/src/components/ui/Button.jsx
Original file line number Diff line number Diff line change
@@ -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 <button> by default (correct role, keyboard, AT support).
* - `aria-busy` is set while loading; a visually-hidden label keeps AT informed.
* - `aria-disabled` + `disabled` are both applied when disabled.
* - Focus ring driven by :focus-visible (invisible to mouse users).
*
* Usage:
* <Button variant="primary" size="md" onClick={handleClick}>Save</Button>
* <Button variant="danger" loading>Deleting…</Button>
* <Button variant="secondary" disabled>Unavailable</Button>
*/

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 (
<button
type={type}
className={classes}
data-size={size}
disabled={isDisabled}
aria-disabled={isDisabled ? true : undefined}
aria-busy={loading ? true : undefined}
onClick={!isDisabled ? onClick : undefined}
{...rest}
>
{loading && (
<span className="ds-btn__spinner" aria-hidden="true" />
)}
{loading && (
<span className="ds-visually-hidden">Loading</span>
)}
{children}
</button>
);
}
156 changes: 156 additions & 0 deletions frontend/src/components/ui/Button.test.jsx
Original file line number Diff line number Diff line change
@@ -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(<Button>Click me</Button>);
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument();
});

it('renders with default type="button"', () => {
render(<Button>Submit</Button>);
expect(screen.getByRole('button')).toHaveAttribute('type', 'button');
});

it('renders with type="submit" when specified', () => {
render(<Button type="submit">Submit</Button>);
expect(screen.getByRole('button')).toHaveAttribute('type', 'submit');
});

// — variants —
it('applies primary variant class by default', () => {
render(<Button>Primary</Button>);
expect(screen.getByRole('button')).toHaveClass('ds-btn--primary');
});

it('applies secondary variant class', () => {
render(<Button variant="secondary">Secondary</Button>);
expect(screen.getByRole('button')).toHaveClass('ds-btn--secondary');
});

it('applies danger variant class', () => {
render(<Button variant="danger">Danger</Button>);
expect(screen.getByRole('button')).toHaveClass('ds-btn--danger');
});

it('applies ghost variant class', () => {
render(<Button variant="ghost">Ghost</Button>);
expect(screen.getByRole('button')).toHaveClass('ds-btn--ghost');
});

// — sizes —
it('applies md size by default', () => {
render(<Button>Medium</Button>);
expect(screen.getByRole('button')).toHaveAttribute('data-size', 'md');
});

it('applies sm size', () => {
render(<Button size="sm">Small</Button>);
expect(screen.getByRole('button')).toHaveAttribute('data-size', 'sm');
});

it('applies lg size', () => {
render(<Button size="lg">Large</Button>);
expect(screen.getByRole('button')).toHaveAttribute('data-size', 'lg');
});

// — disabled state —
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});

it('sets aria-disabled when disabled', () => {
render(<Button disabled>Disabled</Button>);
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(<Button disabled onClick={onClick}>Disabled</Button>);
await user.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});

// — loading state —
it('shows spinner when loading', () => {
render(<Button loading>Save</Button>);
expect(screen.getByRole('button').querySelector('.ds-btn__spinner')).toBeInTheDocument();
});

it('sets aria-busy when loading', () => {
render(<Button loading>Save</Button>);
expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
});

it('is disabled when loading', () => {
render(<Button loading>Save</Button>);
expect(screen.getByRole('button')).toBeDisabled();
});

it('does not call onClick when loading', async () => {
const user = userEvent.setup();
const onClick = vi.fn();
render(<Button loading onClick={onClick}>Save</Button>);
await user.click(screen.getByRole('button'));
expect(onClick).not.toHaveBeenCalled();
});

it('applies loading class when loading', () => {
render(<Button loading>Save</Button>);
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(<Button onClick={onClick}>Click me</Button>);
await user.click(screen.getByRole('button'));
expect(onClick).toHaveBeenCalledTimes(1);
});

// — fullWidth —
it('applies full width class when fullWidth is true', () => {
render(<Button fullWidth>Full</Button>);
expect(screen.getByRole('button')).toHaveClass('ds-btn--full');
});

// — custom className —
it('merges custom className', () => {
render(<Button className="my-custom">Custom</Button>);
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(<Button onClick={onClick}>Press me</Button>);
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(<Button onClick={onClick}>Press me</Button>);
screen.getByRole('button').focus();
await user.keyboard(' ');
expect(onClick).toHaveBeenCalledTimes(1);
});

// — base class —
it('always has the ds-btn base class', () => {
render(<Button>Base</Button>);
expect(screen.getByRole('button')).toHaveClass('ds-btn');
});
});
1 change: 1 addition & 0 deletions frontend/src/components/ui/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Loading
Loading