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 * from './Badge';
|
||||||
export { default as Input } from './Input.svelte';
|
export * from './Button';
|
||||||
|
|||||||
Reference in New Issue
Block a user