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