feat(shared/ui): implement brutalist Button component replacing placeholder

This commit is contained in:
Ilia Mashkov
2026-03-06 23:15:09 +03:00
parent ac2eb6ba0b
commit cb3d05b094
6 changed files with 142 additions and 2 deletions

View File

@@ -0,0 +1,58 @@
<script module>
import { defineMeta } from '@storybook/addon-svelte-csf';
import Button from './Button.svelte';
const { Story } = defineMeta({
title: 'Shared/Button',
component: Button,
tags: ['autodocs'],
parameters: {
layout: 'centered',
docs: { description: { component: 'Brutalist CTA button. Hard shadow, uppercase, hover translates 2px.' } },
},
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'outline', 'ghost'] },
size: { control: 'select', options: ['sm', 'md', 'lg'] },
},
});
</script>
<Story name="Primary" args={{ variant: 'primary', size: 'md' }}>
{#snippet template(args)}
<Button {...args}>Primary</Button>
{/snippet}
</Story>
<Story name="Secondary" args={{ variant: 'secondary', size: 'md' }}>
{#snippet template(args)}
<Button {...args}>Secondary</Button>
{/snippet}
</Story>
<Story name="Outline" args={{ variant: 'outline', size: 'md' }}>
{#snippet template(args)}
<Button {...args}>Outline</Button>
{/snippet}
</Story>
<Story name="Ghost" args={{ variant: 'ghost', size: 'md' }}>
{#snippet template(args)}
<Button {...args}>Ghost</Button>
{/snippet}
</Story>
<Story name="All sizes">
{#snippet template()}
<div class="flex gap-4 items-center">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
</div>
{/snippet}
</Story>
<Story name="Disabled">
{#snippet template()}
<Button disabled>Disabled</Button>
{/snippet}
</Story>

View File

@@ -0,0 +1,58 @@
<!--
Component: Button
Brutalist button. 3px border, hard shadow on hover, uppercase, zero radius.
Hover: translates 2px down-right with reduced shadow for press feel.
-->
<script lang="ts">
import { cn } from '$shared/lib/cn';
import type { Snippet } from 'svelte';
import type { HTMLButtonAttributes } from 'svelte/elements';
import type { ButtonSize, ButtonVariant } from './types';
interface Props extends HTMLButtonAttributes {
/** Visual style @default 'primary' */
variant?: ButtonVariant;
/** Size preset @default 'md' */
size?: ButtonSize;
children?: Snippet;
class?: string;
}
let {
variant = 'primary',
size = 'md',
children,
class: className,
type = 'button',
disabled,
...rest
}: Props = $props();
const base =
'brutal-border transition-all duration-200 ' +
'hover:translate-x-[2px] hover:translate-y-[2px] hover:shadow-[6px_6px_0_var(--carbon-black)] ' +
'active:translate-x-[4px] active:translate-y-[4px] active:shadow-[4px_4px_0_var(--carbon-black)] ' +
'uppercase tracking-wider inline-flex items-center justify-center ' +
'disabled:opacity-50 disabled:cursor-not-allowed disabled:pointer-events-none';
const variantStyles: Record<ButtonVariant, string> = {
primary: 'brutal-shadow bg-burnt-oxide text-ochre-clay',
secondary: 'brutal-shadow bg-slate-indigo text-ochre-clay',
outline: 'brutal-shadow bg-transparent text-carbon-black',
ghost: 'bg-ochre-clay text-carbon-black shadow-none hover:shadow-[6px_6px_0_var(--carbon-black)]',
};
const sizeStyles: Record<ButtonSize, string> = {
sm: 'px-4 py-2 text-sm',
md: 'px-6 py-3 text-base',
lg: 'px-8 py-4 text-lg',
};
const classes = $derived(cn(base, variantStyles[variant], sizeStyles[size], className));
</script>
<button {type} {disabled} class={classes} {...rest}>
{#if children}
{@render children()}
{/if}
</button>

View File

@@ -0,0 +1,20 @@
import { render } from '@testing-library/svelte';
import { describe, expect, it } from 'vitest';
import Button from './Button.svelte';
describe('Button', () => {
it('renders children', () => {
const { getByText } = render(Button, { props: { children: () => 'Click me' } });
expect(getByText('Click me')).toBeTruthy();
});
it('is disabled when disabled prop is true', () => {
const { container } = render(Button, { props: { disabled: true } });
expect(container.querySelector('button')?.disabled).toBe(true);
});
it('applies primary variant styles', () => {
const { container } = render(Button, { props: { variant: 'primary' } });
expect(container.querySelector('button')?.className).toContain('bg-burnt-oxide');
});
});

View File

@@ -0,0 +1,2 @@
export { default as Button } from './Button.svelte';
export type { ButtonVariant, ButtonSize } from './types';

View File

@@ -0,0 +1,2 @@
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
export type ButtonSize = 'sm' | 'md' | 'lg';

View File

@@ -1,2 +1,2 @@
export { default as Button } from './Button.svelte';
export { default as Input } from './Input.svelte';
export * from './Badge';
export * from './Button';