From c2f61543f11d6aceec12621f2781bdfdd9dda2a0 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:26:35 +0100 Subject: [PATCH] feat: Button and Input primitives --- .../primitives/Button/Button.module.css | 47 +++++++++++++++++++ .../primitives/Button/Button.test.tsx | 29 ++++++++++++ .../primitives/Button/Button.tsx | 31 ++++++++++++ .../primitives/Input/Input.module.css | 29 ++++++++++++ src/design-system/primitives/Input/Input.tsx | 23 +++++++++ 5 files changed, 159 insertions(+) create mode 100644 src/design-system/primitives/Button/Button.module.css create mode 100644 src/design-system/primitives/Button/Button.test.tsx create mode 100644 src/design-system/primitives/Button/Button.tsx create mode 100644 src/design-system/primitives/Input/Input.module.css create mode 100644 src/design-system/primitives/Input/Input.tsx diff --git a/src/design-system/primitives/Button/Button.module.css b/src/design-system/primitives/Button/Button.module.css new file mode 100644 index 0000000..6f6f24a --- /dev/null +++ b/src/design-system/primitives/Button/Button.module.css @@ -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; } diff --git a/src/design-system/primitives/Button/Button.test.tsx b/src/design-system/primitives/Button/Button.test.tsx new file mode 100644 index 0000000..c72858c --- /dev/null +++ b/src/design-system/primitives/Button/Button.test.tsx @@ -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() + expect(screen.getByRole('button', { name: 'Click me' })).toBeInTheDocument() + }) + + it('fires onClick', async () => { + const onClick = vi.fn() + const user = userEvent.setup() + render() + await user.click(screen.getByRole('button')) + expect(onClick).toHaveBeenCalledOnce() + }) + + it('shows spinner when loading', () => { + render() + expect(screen.getByRole('status')).toBeInTheDocument() + }) + + it('is disabled when loading', () => { + render() + expect(screen.getByRole('button')).toBeDisabled() + }) +}) diff --git a/src/design-system/primitives/Button/Button.tsx b/src/design-system/primitives/Button/Button.tsx new file mode 100644 index 0000000..14be273 --- /dev/null +++ b/src/design-system/primitives/Button/Button.tsx @@ -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 { + 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 ( + + ) +} diff --git a/src/design-system/primitives/Input/Input.module.css b/src/design-system/primitives/Input/Input.module.css new file mode 100644 index 0000000..1fe5bf8 --- /dev/null +++ b/src/design-system/primitives/Input/Input.module.css @@ -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; } diff --git a/src/design-system/primitives/Input/Input.tsx b/src/design-system/primitives/Input/Input.tsx new file mode 100644 index 0000000..f04af76 --- /dev/null +++ b/src/design-system/primitives/Input/Input.tsx @@ -0,0 +1,23 @@ +import styles from './Input.module.css' +import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react' + +interface InputProps extends InputHTMLAttributes { + icon?: ReactNode +} + +export const Input = forwardRef( + ({ icon, className, ...rest }, ref) => { + return ( +
+ {icon && {icon}} + +
+ ) + }, +) + +Input.displayName = 'Input'