feat(shared/ui): implement brutalist Button component replacing placeholder
This commit is contained in:
58
src/shared/ui/Button/Button.stories.svelte
Normal file
58
src/shared/ui/Button/Button.stories.svelte
Normal 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>
|
||||
58
src/shared/ui/Button/Button.svelte
Normal file
58
src/shared/ui/Button/Button.svelte
Normal 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>
|
||||
20
src/shared/ui/Button/Button.test.ts
Normal file
20
src/shared/ui/Button/Button.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
2
src/shared/ui/Button/index.ts
Normal file
2
src/shared/ui/Button/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { default as Button } from './Button.svelte';
|
||||
export type { ButtonVariant, ButtonSize } from './types';
|
||||
2
src/shared/ui/Button/types.ts
Normal file
2
src/shared/ui/Button/types.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
@@ -1,2 +1,2 @@
|
||||
export { default as Button } from './Button.svelte';
|
||||
export { default as Input } from './Input.svelte';
|
||||
export * from './Badge';
|
||||
export * from './Button';
|
||||
|
||||
Reference in New Issue
Block a user