2026-02-24 17:58:56 +03:00
|
|
|
<!--
|
|
|
|
|
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 {
|
2026-03-02 22:19:35 +03:00
|
|
|
/**
|
|
|
|
|
* Visual style variant
|
|
|
|
|
* @default 'secondary'
|
|
|
|
|
*/
|
2026-02-24 17:58:56 +03:00
|
|
|
variant?: ButtonVariant;
|
2026-03-02 22:19:35 +03:00
|
|
|
/**
|
|
|
|
|
* Button size
|
|
|
|
|
* @default 'md'
|
|
|
|
|
*/
|
2026-02-24 17:58:56 +03:00
|
|
|
size?: ButtonSize;
|
2026-03-02 22:19:35 +03:00
|
|
|
/**
|
|
|
|
|
* Icon snippet
|
|
|
|
|
*/
|
2026-02-24 17:58:56 +03:00
|
|
|
icon?: Snippet;
|
2026-03-02 22:19:35 +03:00
|
|
|
/**
|
|
|
|
|
* Icon placement
|
|
|
|
|
* @default 'left'
|
|
|
|
|
*/
|
2026-02-24 17:58:56 +03:00
|
|
|
iconPosition?: IconPosition;
|
2026-03-02 22:19:35 +03:00
|
|
|
/**
|
|
|
|
|
* Active toggle state
|
|
|
|
|
* @default false
|
|
|
|
|
*/
|
2026-02-24 17:58:56 +03:00
|
|
|
active?: boolean;
|
|
|
|
|
/**
|
2026-03-02 22:19:35 +03:00
|
|
|
* Tap animation
|
|
|
|
|
* Primary uses translate, others use scale
|
|
|
|
|
* @default true
|
2026-02-24 17:58:56 +03:00
|
|
|
*/
|
|
|
|
|
animate?: boolean;
|
2026-03-02 22:19:35 +03:00
|
|
|
/**
|
|
|
|
|
* Content snippet
|
|
|
|
|
*/
|
2026-02-24 17:58:56 +03:00
|
|
|
children?: Snippet;
|
2026-03-02 22:19:35 +03:00
|
|
|
/**
|
|
|
|
|
* CSS classes
|
|
|
|
|
*/
|
2026-02-24 17:58:56 +03:00
|
|
|
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',
|
2026-04-16 16:32:41 +03:00
|
|
|
'text-secondary',
|
2026-02-24 17:58:56 +03:00
|
|
|
'border border-transparent',
|
2026-02-27 12:25:16 +03:00
|
|
|
'hover:bg-transparent dark:hover:bg-transparent',
|
|
|
|
|
'hover:text-brand dark:hover:text-brand',
|
|
|
|
|
'active:bg-transparent dark:active:bg-transparent',
|
2026-02-24 17:58:56 +03:00
|
|
|
'disabled:text-neutral-400 dark:disabled:text-neutral-600',
|
|
|
|
|
'disabled:cursor-not-allowed',
|
|
|
|
|
),
|
|
|
|
|
icon: cn(
|
|
|
|
|
'bg-surface dark:bg-dark-bg',
|
2026-04-16 16:32:41 +03:00
|
|
|
'text-secondary',
|
2026-02-24 17:58:56 +03:00
|
|
|
'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',
|
|
|
|
|
),
|
2026-02-27 12:25:16 +03:00
|
|
|
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
|
2026-03-04 16:51:49 +03:00
|
|
|
'hover:bg-paper/50 dark:hover:bg-dark-card/50',
|
2026-02-27 12:25:16 +03:00
|
|
|
'hover:text-neutral-900 dark:hover:text-neutral-100',
|
|
|
|
|
'hover:border-black/5 dark:hover:border-white/10',
|
|
|
|
|
// Press
|
2026-03-04 16:51:49 +03:00
|
|
|
'active:bg-paper/70 dark:active:bg-dark-card/70',
|
2026-02-27 12:25:16 +03:00
|
|
|
// Disabled
|
|
|
|
|
'disabled:text-neutral-300 dark:disabled:text-neutral-600',
|
|
|
|
|
'disabled:cursor-not-allowed',
|
|
|
|
|
),
|
2026-02-24 17:58:56 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sizeStyles: Record<ButtonSize, string> = {
|
2026-04-17 09:41:14 +03:00
|
|
|
xs: 'h-6 px-2 text-3xs gap-1',
|
|
|
|
|
sm: 'h-8 px-3 text-2xs gap-1.5',
|
|
|
|
|
md: 'h-10 px-4 text-xs gap-2',
|
|
|
|
|
lg: 'h-12 px-6 text-xs gap-2',
|
|
|
|
|
xl: 'h-14 px-8 text-sm gap-2.5',
|
2026-02-24 17:58:56 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// 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',
|
2026-02-27 12:25:16 +03:00
|
|
|
tertiary:
|
2026-03-04 16:51:49 +03:00
|
|
|
'bg-paper dark:bg-dark-card border-black/10 dark:border-white/10 shadow-sm text-neutral-900 dark:text-neutral-100',
|
2026-03-02 22:19:35 +03:00
|
|
|
ghost: 'bg-transparent dark:bg-transparent text-brand dark:text-brand',
|
2026-02-24 17:58:56 +03:00
|
|
|
outline: 'bg-surface dark:bg-paper border-brand',
|
2026-04-16 16:32:41 +03:00
|
|
|
icon: 'bg-paper dark:bg-paper text-brand border-subtle',
|
2026-02-24 17:58:56 +03:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const classes = $derived(cn(
|
|
|
|
|
// Base
|
|
|
|
|
'inline-flex items-center justify-center',
|
2026-02-27 12:25:16 +03:00
|
|
|
'font-primary font-bold tracking-tight uppercase',
|
2026-02-24 17:58:56 +03:00
|
|
|
'rounded-none',
|
|
|
|
|
'transition-all duration-200',
|
|
|
|
|
'select-none',
|
|
|
|
|
'outline-none',
|
2026-02-27 12:25:16 +03:00
|
|
|
'cursor-pointer',
|
2026-04-16 16:32:41 +03:00
|
|
|
'focus-ring',
|
2026-02-24 17:58:56 +03:00
|
|
|
'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>
|