Files
frontend-svelte/src/shared/ui/Button/Button.svelte

227 lines
7.0 KiB
Svelte

<!--
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 {
/**
* Visual style variant
* @default 'secondary'
*/
variant?: ButtonVariant;
/**
* Button size
* @default 'md'
*/
size?: ButtonSize;
/**
* Icon snippet
*/
icon?: Snippet;
/**
* Icon placement
* @default 'left'
*/
iconPosition?: IconPosition;
/**
* Active toggle state
* @default false
*/
active?: boolean;
/**
* Tap animation
* Primary uses translate, others use scale
* @default true
*/
animate?: boolean;
/**
* Content snippet
*/
children?: Snippet;
/**
* CSS classes
*/
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);
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-transparent dark:hover:bg-transparent',
'hover:text-brand dark:hover:text-brand',
'active:bg-transparent dark:active:bg-transparent',
'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',
),
tertiary: cn(
// Font override — must come after base in cn() to win via tailwind-merge
'font-secondary font-medium normal-case tracking-normal',
// Inactive state
'bg-transparent',
'text-neutral-400 dark:text-neutral-400',
'border border-transparent',
// Hover (inactive) — semi-transparent lift, no bg-paper token
'hover:bg-white/50 dark:hover:bg-[#1e1e1e]/50',
'hover:text-neutral-900 dark:hover:text-neutral-100',
'hover:border-black/5 dark:hover:border-white/10',
// Press
'active:bg-white/70 dark:active:bg-[#1e1e1e]/70',
// Disabled
'disabled:text-neutral-300 dark:disabled:text-neutral-600',
'disabled:cursor-not-allowed',
),
};
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',
};
const activeStyles: Partial<Record<ButtonVariant, string>> = {
secondary: 'bg-paper dark:bg-paper shadow-sm border-black/20 dark:border-white/20',
tertiary:
'bg-paper dark:bg-[#1e1e1e] border-black/10 dark:border-white/10 shadow-sm text-neutral-900 dark:text-neutral-100',
ghost: 'bg-transparent dark:bg-transparent text-brand dark:text-brand',
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-primary font-bold tracking-tight uppercase',
'rounded-none',
'transition-all duration-200',
'select-none',
'outline-none',
'cursor-pointer',
'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>