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 // the min-h is the button's real height (32/36/40 in Figma), so py has to stay
60 // small enough that line-height + padding + border never exceeds it — otherwise a
61 // button with a label grows past the min-h while an icon-only one stays on it, and
62 // the two render at different heights side by side. py only takes over as the
63 // floor when a label wraps to a second line.
64 size: {
65 sm: "min-h-8 gap-1.5 px-2 py-1 typography-paragraph-small",
66 md: "min-h-9 gap-2 px-3 py-1 typography-paragraph-regular",
67 lg: "min-h-10 gap-2.5 px-4 py-1 typography-paragraph-large"
68 },
69 insetShadow: {
70 true: "",
71 false: ""
72 }
73 },
74 compoundVariants: [
75 // raised variants draw inset depth via a ::before layer. pressing shifts the
76 // content wrapper rather than the button itself, so the box stays put.
77 {
78 variant: "default",
79 insetShadow: true,
80 class: [
81 "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)]",
82 "hover:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-strong-hover)]",
83 "active:*:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-shadow-inner-subtle)]",
84 "disabled:before:shadow-none disabled:active:*:translate-y-0"
85 ]
86 },
87 {
88 variant: "primary",
89 insetShadow: true,
90 class: [
91 "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)]",
92 "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)]",
93 "disabled:before:shadow-none disabled:active:*:translate-y-0"
94 ]
95 }
96 ],
97 defaultVariants: {
98 variant: "default",
99 size: "md",
100 insetShadow: false
101 }
102 });
103
104 export type ButtonVariants = VariantProps<typeof button>;
105</script>
106
107<script lang="ts">
108 import type { ResolvedPathname } from "$app/types";
109 import type { Component, Snippet } from "svelte";
110 import type {
111 HTMLAnchorAttributes,
112 HTMLButtonAttributes,
113 SvelteHTMLElements
114 } from "svelte/elements";
115 import Spinner from "./Spinner.svelte";
116
117 interface Props extends Omit<HTMLAnchorAttributes & HTMLButtonAttributes, "class" | "children"> {
118 variant?: ButtonVariants["variant"];
119 size?: ButtonVariants["size"];
120 insetShadow?: boolean;
121 href?: ResolvedPathname;
122 type?: "button" | "submit" | "reset";
123 disabled?: boolean;
124 icon?: Component<SvelteHTMLElements["svg"]>;
125 iconSide?: string;
126 class?: string;
127 loading?: boolean;
128 spinnerClass?: string;
129 children?: Snippet;
130 }
131
132 let {
133 variant = "default",
134 size = "md",
135 insetShadow = false,
136 href,
137 type = "button",
138 disabled = false,
139 icon,
140 iconSide = "left",
141 class: className,
142 loading = false,
143 spinnerClass,
144 children,
145 ...rest
146 }: Props = $props();
147
148 const classes = $derived(button({ variant, size, insetShadow, class: className }));
149 const spinnerSize = $derived(spinnerClass ?? (size === "lg" ? "size-5" : "size-4"));
150 const iconSize = $derived(size === "lg" ? "size-5" : "size-4");
151 const Icon = $derived(icon);
152</script>
153
154{#snippet content()}
155 <!-- single wrapper so the press animation moves the label/icon, not the button box.
156 gap-[inherit] keeps the size variant's gap working across the extra level, and the
157 transition matches the ::before shadow's so both halves of the press ease together. -->
158 <span
159 class="inline-flex min-w-0 items-center justify-center gap-[inherit] transition-transform duration-150 ease-in-out"
160 >
161 {#if loading}
162 <Spinner class={spinnerSize} />
163 {:else}
164 {#if Icon && iconSide == "left"}
165 <Icon class="{iconSize} shrink-0" aria-hidden="true" />
166 {/if}
167 {#if children}
168 {@render children()}
169 {/if}
170 {#if Icon && iconSide == "right"}
171 <Icon class="{iconSize} shrink-0" aria-hidden="true" />
172 {/if}
173 {/if}
174 </span>
175{/snippet}
176
177{#if href}
178 <a
179 {href}
180 class={classes}
181 data-variant={variant}
182 aria-disabled={disabled || loading ? "true" : undefined}
183 tabindex={disabled ? -1 : undefined}
184 {...rest}
185 >
186 {@render content()}
187 </a>
188{:else}
189 <button
190 {type}
191 disabled={disabled || loading}
192 class={classes}
193 data-variant={variant}
194 aria-busy={loading}
195 {...rest}
196 >
197 {@render content()}
198 </button>
199{/if}