feat(Button): shared button component with different sizes and variants
This commit is contained in:
120
src/shared/ui/Button/Button.stories.svelte
Normal file
120
src/shared/ui/Button/Button.stories.svelte
Normal file
@@ -0,0 +1,120 @@
|
||||
<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: {
|
||||
docs: {
|
||||
description: {
|
||||
component: '',
|
||||
},
|
||||
story: { inline: false },
|
||||
},
|
||||
layout: 'centered',
|
||||
},
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'icon', 'outline', 'ghost'],
|
||||
defaultValue: 'secondary',
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['xs', 'sm', 'md', 'lg', 'xl'],
|
||||
defaultValue: 'md',
|
||||
},
|
||||
iconPosition: {
|
||||
control: 'select',
|
||||
options: ['left', 'right'],
|
||||
defaultValue: 'left',
|
||||
},
|
||||
active: {
|
||||
control: 'boolean',
|
||||
defaultValue: false,
|
||||
},
|
||||
animate: {
|
||||
control: 'boolean',
|
||||
defaultValue: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import XIcon from '@lucide/svelte/icons/x';
|
||||
import ButtonGroup from './ButtonGroup.svelte';
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default/Basic"
|
||||
parameters={{ docs: { description: { story: 'Standard text button at all sizes' } } }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
<ButtonGroup>
|
||||
<Button {...args} size="xs">xs</Button>
|
||||
<Button {...args} size="sm">sm</Button>
|
||||
<Button {...args} size="md">md</Button>
|
||||
<Button {...args} size="lg">lg</Button>
|
||||
<Button {...args} size="xl">xl</Button>
|
||||
</ButtonGroup>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Default/With Icon"
|
||||
args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
<Button {...args}>
|
||||
{#snippet icon()}
|
||||
<XIcon />
|
||||
{/snippet}
|
||||
Close
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Primary"
|
||||
args={{ variant: 'primary', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
<Button {...args}>Primary</Button>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Secondary"
|
||||
args={{ variant: 'secondary', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
<Button {...args}>Secondary</Button>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Icon"
|
||||
args={{ variant: 'icon', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
<Button {...args}>
|
||||
{#snippet icon()}
|
||||
<XIcon />
|
||||
{/snippet}
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Ghost"
|
||||
args={{ variant: 'ghost', size: 'md', iconPosition: 'left', active: false, animate: true }}
|
||||
>
|
||||
{#snippet template(args)}
|
||||
<Button {...args}>
|
||||
Ghost
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Story>
|
||||
185
src/shared/ui/Button/Button.svelte
Normal file
185
src/shared/ui/Button/Button.svelte
Normal file
@@ -0,0 +1,185 @@
|
||||
<!--
|
||||
Component: Button
|
||||
design-system button. Uppercase, zero border-radius, Space Grotesk.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { cn } from '$shared/shadcn/utils/shadcn-utils';
|
||||
import type { Snippet } from 'svelte';
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
import type {
|
||||
ButtonSize,
|
||||
ButtonVariant,
|
||||
IconPosition,
|
||||
} from './types';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
/** Svelte snippet rendered as the icon. */
|
||||
icon?: Snippet;
|
||||
iconPosition?: IconPosition;
|
||||
active?: boolean;
|
||||
/**
|
||||
* When true (default), adds `active:scale-[0.97]` on tap via CSS.
|
||||
* Primary variant is excluded from scale — it shifts via translate instead.
|
||||
*/
|
||||
animate?: boolean;
|
||||
children?: Snippet;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'secondary',
|
||||
size = 'md',
|
||||
icon,
|
||||
iconPosition = 'left',
|
||||
active = false,
|
||||
animate = true,
|
||||
children,
|
||||
class: className,
|
||||
type = 'button',
|
||||
disabled,
|
||||
...rest
|
||||
}: Props = $props();
|
||||
|
||||
// Square sizing when icon is present but there is no text label
|
||||
const isIconOnly = $derived(!!icon && !children);
|
||||
|
||||
// ── Variant base styles ──────────────────────────────────────────────────────
|
||||
const variantStyles: Record<ButtonVariant, string> = {
|
||||
primary: cn(
|
||||
'bg-swiss-red text-white',
|
||||
'hover:bg-swiss-red/90',
|
||||
'active:bg-swiss-red/80',
|
||||
'border border-swiss-red',
|
||||
'shadow-[0.125rem_0.125rem_0_0_rgba(0,0,0,0.1)]',
|
||||
'hover:shadow-[0.1875rem_0.1875rem_0_0_rgba(0,0,0,0.15)]',
|
||||
'active:shadow-[0.0625rem_0.0625rem_0_0_rgba(0,0,0,0.08)]',
|
||||
'active:translate-x-[0.0625rem] active:translate-y-[0.0625rem]',
|
||||
'disabled:bg-neutral-300 dark:disabled:bg-neutral-700',
|
||||
'disabled:text-neutral-500 dark:disabled:text-neutral-500',
|
||||
'disabled:border-neutral-300 dark:disabled:border-neutral-700',
|
||||
'disabled:shadow-none',
|
||||
'disabled:cursor-not-allowed',
|
||||
'disabled:transform-none',
|
||||
),
|
||||
secondary: cn(
|
||||
'bg-surface dark:bg-paper',
|
||||
'text-swiss-black dark:text-neutral-200',
|
||||
'border border-black/10 dark:border-white/10',
|
||||
'hover:bg-paper dark:hover:bg-neutral-800',
|
||||
'hover:shadow-sm',
|
||||
'active:bg-neutral-100 dark:active:bg-neutral-700',
|
||||
'disabled:bg-neutral-100 dark:disabled:bg-neutral-800',
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
outline: cn(
|
||||
'bg-transparent',
|
||||
'text-swiss-black dark:text-neutral-200',
|
||||
'border border-black/20 dark:border-white/20',
|
||||
'hover:bg-surface dark:hover:bg-paper',
|
||||
'hover:border-brand',
|
||||
'active:bg-paper dark:active:bg-neutral-700',
|
||||
'disabled:border-neutral-300 dark:disabled:border-neutral-700',
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
ghost: cn(
|
||||
'bg-transparent',
|
||||
'text-neutral-500 dark:text-neutral-400',
|
||||
'border border-transparent',
|
||||
'hover:bg-paper dark:hover:bg-paper',
|
||||
'hover:text-swiss-black dark:hover:text-neutral-200',
|
||||
'hover:border-black/5 dark:hover:border-white/5',
|
||||
'active:bg-surface dark:active:bg-neutral-700',
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
icon: cn(
|
||||
'bg-surface dark:bg-dark-bg',
|
||||
'text-neutral-500 dark:text-neutral-400',
|
||||
'border border-transparent',
|
||||
'hover:bg-paper dark:hover:bg-paper',
|
||||
'hover:text-brand',
|
||||
'hover:border-black/5 dark:hover:border-white/10',
|
||||
'active:bg-paper dark:active:bg-neutral-700',
|
||||
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
||||
'disabled:cursor-not-allowed',
|
||||
),
|
||||
};
|
||||
|
||||
// ── Size styles ───────────────────────────────────────────────────────────────
|
||||
const sizeStyles: Record<ButtonSize, string> = {
|
||||
xs: 'h-6 px-2 text-[9px] gap-1',
|
||||
sm: 'h-8 px-3 text-[10px] gap-1.5',
|
||||
md: 'h-10 px-4 text-[11px] gap-2',
|
||||
lg: 'h-12 px-6 text-[12px] gap-2',
|
||||
xl: 'h-14 px-8 text-[13px] gap-2.5',
|
||||
};
|
||||
|
||||
// Square padding for icon-only mode
|
||||
const iconSizeStyles: Record<ButtonSize, string> = {
|
||||
xs: 'h-6 w-6 p-1',
|
||||
sm: 'h-8 w-8 p-1.5',
|
||||
md: 'h-10 w-10 p-2',
|
||||
lg: 'h-12 w-12 p-2.5',
|
||||
xl: 'h-14 w-14 p-3',
|
||||
};
|
||||
|
||||
// ── Active state overrides (per variant) ─────────────────────────────────────
|
||||
const activeStyles: Partial<Record<ButtonVariant, string>> = {
|
||||
secondary: 'bg-paper dark:bg-paper shadow-sm border-black/20 dark:border-white/20',
|
||||
ghost: 'bg-paper dark:bg-paper text-swiss-black dark:text-neutral-200',
|
||||
outline: 'bg-surface dark:bg-paper border-brand',
|
||||
icon: 'bg-paper dark:bg-paper text-brand border-black/5 dark:border-white/10',
|
||||
};
|
||||
|
||||
const classes = $derived(cn(
|
||||
// Base
|
||||
'inline-flex items-center justify-center',
|
||||
'font-["Space_Grotesk"] font-bold tracking-tight uppercase',
|
||||
'rounded-none',
|
||||
'transition-all duration-200',
|
||||
'select-none',
|
||||
'outline-none',
|
||||
'focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2',
|
||||
'focus-visible:ring-offset-surface dark:focus-visible:ring-offset-dark-bg',
|
||||
'disabled:cursor-not-allowed disabled:pointer-events-none',
|
||||
// Variant
|
||||
variantStyles[variant],
|
||||
// Size (square when icon-only)
|
||||
isIconOnly ? iconSizeStyles[size] : sizeStyles[size],
|
||||
// Animate (CSS tap scale — excluded for primary which uses translate instead)
|
||||
animate && !disabled && variant !== 'primary' && 'active:scale-[0.97]',
|
||||
// Active override
|
||||
active && activeStyles[variant],
|
||||
// Consumer override
|
||||
className,
|
||||
));
|
||||
</script>
|
||||
|
||||
<button
|
||||
{type}
|
||||
{disabled}
|
||||
class={classes}
|
||||
{...rest}
|
||||
>
|
||||
{#if icon && iconPosition === 'left'}
|
||||
<span class="shrink-0 flex items-center justify-center">
|
||||
{@render icon()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if children}
|
||||
<span class="whitespace-nowrap leading-none">
|
||||
{@render children()}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
{#if icon && iconPosition === 'right'}
|
||||
<span class="shrink-0 flex items-center justify-center">
|
||||
{@render icon()}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
Reference in New Issue
Block a user