This repository has no description
1<script module lang="ts">
2 import { tv, type VariantProps } from "tailwind-variants";
3
4 export const button = tv({
5 base: [
6 "inline-flex cursor-pointer items-center justify-center overflow-hidden rounded-l-(--btn-radius-l) rounded-r-(--btn-radius-r) no-underline select-none [--btn-radius-l:var(--btn-radius)] [--btn-radius-r:var(--btn-radius)] [--btn-radius:var(--radius-sm)]",
7 "transition-colors duration-150 ease-in-out",
8 "hover:no-underline",
9 "focus-visible:outline-2 focus-visible:outline-offset-2",
10 // each variant paints its own disabled surface (see the variant blocks) —
11 // Figma models disabled as a distinct fill/text pair, not a dimmed enabled state
12 "disabled:pointer-events-none aria-disabled:pointer-events-none"
13 ],
14 variants: {
15 variant: {
16 default: [
17 "border border-border-default bg-background-default text-foreground-default",
18 "hover:bg-background-subtle",
19 "focus-visible:outline-border-focus",
20 "disabled:border-transparent disabled:bg-background-subtle disabled:text-foreground-subtle",
21 "aria-disabled:border-transparent aria-disabled:bg-background-subtle aria-disabled:text-foreground-subtle"
22 ],
23 primary: [
24 "border border-border-success bg-background-success-emphasis text-foreground-on-emphasis",
25 "hover:bg-background-success-emphasis-hover",
26 "focus-visible:outline-background-success-emphasis",
27 "disabled:border-transparent disabled:bg-background-success-subtle disabled:text-foreground-success-disabled",
28 "aria-disabled:border-transparent aria-disabled:bg-background-success-subtle aria-disabled:text-foreground-success-disabled"
29 ],
30 ghost: [
31 "border border-transparent bg-transparent",
32 "hover:bg-background-inset",
33 "focus-visible:outline-border-focus",
34 "disabled:text-foreground-disabled aria-disabled:text-foreground-disabled"
35 ],
36 // danger / success / warning are outline buttons in Figma: default surface +
37 // border, colored text only (bound to the fg-* tokens), no accent fill. They
38 // keep that surface when disabled and only fade the label — the outline is
39 // what carries the variant, so dropping it would read as a different button.
40 danger: [
41 "border border-border-default bg-background-default text-foreground-danger",
42 "hover:bg-background-subtle",
43 "focus-visible:outline-foreground-danger",
44 "disabled:text-foreground-danger-disabled aria-disabled:text-foreground-danger-disabled"
45 ],
46 success: [
47 "border border-border-default bg-background-default text-foreground-success",
48 "hover:bg-background-subtle",
49 "focus-visible:outline-foreground-success",
50 "disabled:text-foreground-success-disabled aria-disabled:text-foreground-success-disabled"
51 ],
52 warning: [
53 "border border-border-default bg-background-default text-foreground-warning",
54 "hover:bg-background-subtle",
55 "focus-visible:outline-foreground-warning",
56 "disabled:text-foreground-warning-disabled aria-disabled:text-foreground-warning-disabled"
57 ]
58 },
59 size: {
60 sm: "min-h-8 gap-1.5 px-2 py-1.5 typography-paragraph-small",
61 md: "min-h-9 gap-2 px-3 py-1.5 typography-paragraph-regular",
62 lg: "min-h-10 gap-2.5 px-4 py-2 typography-paragraph-large"
63 },
64 insetShadow: {
65 true: "",
66 false: ""
67 }
68 },
69 compoundVariants: [
70 // raised variants draw inset depth via a ::before layer. pressing shifts the
71 // content wrapper rather than the button itself, so the box stays put.
72 {
73 variant: "default",
74 insetShadow: true,
75 class: [
76 "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-l-[max(calc(var(--btn-radius-l)-2px),0px)] before:rounded-r-[max(calc(var(--btn-radius-r)-2px),0px)] before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)] before:transition-all before:duration-150 before:content-[''] dark:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)]",
77 "hover:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-strong-hover)]",
78 "active:*:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-shadow-inner-subtle)]",
79 "disabled:before:shadow-none disabled:active:*:translate-y-0"
80 ]
81 },
82 {
83 variant: "primary",
84 insetShadow: true,
85 class: [
86 "relative z-0 before:absolute before:inset-0 before:-z-10 before:rounded-l-[max(calc(var(--btn-radius-l)-2px),0px)] before:rounded-r-[max(calc(var(--btn-radius-r)-2px),0px)] before:shadow-[inset_0_-2px_0_0_var(--color-alpha-dark-25)] before:transition-all before:duration-150 before:content-[''] dark:before:shadow-[inset_0_-2px_0_0_var(--color-alpha-light-25)]",
87 "active:*:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-alpha-dark-50)] dark:active:before:shadow-[inset_0_2px_2px_0_var(--color-alpha-light-50)]",
88 "disabled:before:shadow-none disabled:active:*:translate-y-0"
89 ]
90 }
91 ],
92 defaultVariants: {
93 variant: "default",
94 size: "md",
95 insetShadow: false
96 }
97 });
98
99 export type ButtonVariants = VariantProps<typeof button>;
100</script>
101
102<script lang="ts">
103 import type { ResolvedPathname } from "$app/types";
104 import type { Component, Snippet } from "svelte";
105 import type {
106 HTMLAnchorAttributes,
107 HTMLButtonAttributes,
108 SvelteHTMLElements
109 } from "svelte/elements";
110 import Spinner from "./Spinner.svelte";
111
112 interface Props extends Omit<HTMLAnchorAttributes & HTMLButtonAttributes, "class" | "children"> {
113 variant?: ButtonVariants["variant"];
114 size?: ButtonVariants["size"];
115 insetShadow?: boolean;
116 href?: ResolvedPathname;
117 type?: "button" | "submit" | "reset";
118 disabled?: boolean;
119 icon?: Component<SvelteHTMLElements["svg"]>;
120 iconSide?: string;
121 class?: string;
122 loading?: boolean;
123 spinnerClass?: string;
124 children?: Snippet;
125 }
126
127 let {
128 variant = "default",
129 size = "md",
130 insetShadow = false,
131 href,
132 type = "button",
133 disabled = false,
134 icon,
135 iconSide = "left",
136 class: className,
137 loading = false,
138 spinnerClass,
139 children,
140 ...rest
141 }: Props = $props();
142
143 const classes = $derived(button({ variant, size, insetShadow, class: className }));
144 const spinnerSize = $derived(spinnerClass ?? (size === "lg" ? "size-5" : "size-4"));
145 const iconSize = $derived(size === "lg" ? "size-5" : "size-4");
146 const Icon = $derived(icon);
147</script>
148
149{#snippet content()}
150 <!-- single wrapper so the press animation moves the label/icon, not the button box.
151 gap-[inherit] keeps the size variant's gap working across the extra level, and the
152 transition matches the ::before shadow's so both halves of the press ease together. -->
153 <span
154 class="inline-flex min-w-0 items-center justify-center gap-[inherit] transition-transform duration-150 ease-in-out"
155 >
156 {#if loading}
157 <Spinner class={spinnerSize} />
158 {:else}
159 {#if Icon && iconSide == "left"}
160 <Icon class="{iconSize} shrink-0" aria-hidden="true" />
161 {/if}
162 {#if children}
163 {@render children()}
164 {/if}
165 {#if Icon && iconSide == "right"}
166 <Icon class="{iconSize} shrink-0" aria-hidden="true" />
167 {/if}
168 {/if}
169 </span>
170{/snippet}
171
172{#if href}
173 <a
174 {href}
175 class={classes}
176 aria-disabled={disabled || loading ? "true" : undefined}
177 tabindex={disabled ? -1 : undefined}
178 {...rest}
179 >
180 {@render content()}
181 </a>
182{:else}
183 <button {type} disabled={disabled || loading} class={classes} aria-busy={loading} {...rest}>
184 {@render content()}
185 </button>
186{/if}