feat: Button and Input primitives

This commit is contained in:
hsiegeln
2026-03-18 09:26:35 +01:00
parent 34537fd1a3
commit c2f61543f1
5 changed files with 159 additions and 0 deletions

View File

@@ -0,0 +1,47 @@
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
font-family: var(--font-body);
font-weight: 500;
border-radius: var(--radius-sm);
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn:disabled { opacity: 0.6; cursor: not-allowed; }
.sm { padding: 4px 10px; font-size: 11px; }
.md { padding: 6px 14px; font-size: 12px; }
.primary {
background: var(--amber);
color: white;
border: none;
}
.primary:hover:not(:disabled) { background: var(--amber-deep); }
.secondary {
background: none;
border: 1px solid var(--border);
color: var(--text-secondary);
}
.secondary:hover:not(:disabled) { border-color: var(--text-faint); color: var(--text-primary); }
.danger {
background: none;
border: 1px solid var(--error-border);
color: var(--error);
}
.danger:hover:not(:disabled) { background: var(--error-bg); }
.ghost {
background: none;
border: none;
color: var(--text-secondary);
}
.ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text-primary); }
.hiddenText { visibility: hidden; }

View File

@@ -0,0 +1,29 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Button } from './Button'
describe('Button', () => {
it('renders children text', () => {
render(<Button>Click me</Button>)
expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument()
})
it('fires onClick', async () => {
const onClick = vi.fn()
const user = userEvent.setup()
render(<Button onClick={onClick}>Click</Button>)
await user.click(screen.getByRole('button'))
expect(onClick).toHaveBeenCalledOnce()
})
it('shows spinner when loading', () => {
render(<Button loading>Save</Button>)
expect(screen.getByRole('status')).toBeInTheDocument()
})
it('is disabled when loading', () => {
render(<Button loading>Save</Button>)
expect(screen.getByRole('button')).toBeDisabled()
})
})

View File

@@ -0,0 +1,31 @@
import styles from './Button.module.css'
import { Spinner } from '../Spinner/Spinner'
import type { ButtonHTMLAttributes, ReactNode } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost'
size?: 'sm' | 'md'
loading?: boolean
children: ReactNode
}
export function Button({
variant = 'secondary',
size = 'md',
loading = false,
children,
className,
disabled,
...rest
}: ButtonProps) {
return (
<button
className={`${styles.btn} ${styles[variant]} ${styles[size]} ${className ?? ''}`}
disabled={disabled || loading}
{...rest}
>
{loading && <Spinner size="sm" />}
<span className={loading ? styles.hiddenText : ''}>{children}</span>
</button>
)
}

View File

@@ -0,0 +1,29 @@
.wrap { position: relative; }
.icon {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
color: var(--text-faint);
font-size: 13px;
pointer-events: none;
}
.input {
width: 100%;
padding: 6px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-raised);
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
outline: none;
transition: border-color 0.15s, box-shadow 0.15s;
}
.input::placeholder { color: var(--text-faint); }
.input:focus { border-color: var(--amber); box-shadow: 0 0 0 3px var(--amber-bg); }
.hasIcon { padding-left: 30px; }

View File

@@ -0,0 +1,23 @@
import styles from './Input.module.css'
import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react'
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
icon?: ReactNode
}
export const Input = forwardRef<HTMLInputElement, InputProps>(
({ icon, className, ...rest }, ref) => {
return (
<div className={`${styles.wrap} ${className ?? ''}`}>
{icon && <span className={styles.icon}>{icon}</span>}
<input
ref={ref}
className={`${styles.input} ${icon ? styles.hasIcon : ''}`}
{...rest}
/>
</div>
)
},
)
Input.displayName = 'Input'