Monorepo for Tangled
tangled.org
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 select-none items-center justify-center overflow-hidden rounded-l-(--btn-radius-l) rounded-r-(--btn-radius-r) [--btn-radius:var(--radius-sm)] [--btn-radius-l:var(--btn-radius)] [--btn-radius-r:var(--btn-radius)] no-underline",
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 "bg-transparent text-foreground-muted border border-transparent",
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-base"
51 },
52 insetShadow: {
53 true: "",
54 false: ""
55 }
56 },
57 compoundVariants: [
58 // raised variants draw inset depth via a ::before layer.
59 {
60 variant: "default",
61 insetShadow: true,
62 class: [
63 "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:content-[''] before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-subtle)] before:transition-all before:duration-150",
64 "hover:before:shadow-[inset_0_-2px_0_0_var(--color-shadow-inner-strong-hover)]",
65 "active:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-shadow-inner-subtle)]",
66 "disabled:active:translate-y-0"
67 ]
68 },
69 {
70 variant: "primary",
71 insetShadow: true,
72 class: [
73 "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:content-[''] before:shadow-[inset_0_-2px_0_0_var(--color-alpha-dark-25)] before:transition-all before:duration-150",
74 "active:translate-y-0.5 active:before:shadow-[inset_0_2px_2px_0_var(--color-alpha-dark-50)]",
75 "disabled:active:translate-y-0"
76 ]
77 }
78 ],
79 defaultVariants: {
80 variant: "default",
81 size: "md",
82 insetShadow: false
83 }
84 });
85
86 export type ButtonVariants = VariantProps<typeof button>;
87</script>
88
89<script lang="ts">
90 import type { ResolvedPathname } from "$app/types";
91 import type { Component, Snippet } from "svelte";
92 import type {
93 HTMLAnchorAttributes,
94 HTMLButtonAttributes,
95 SvelteHTMLElements
96 } from "svelte/elements";
97 import Spinner from "./Spinner.svelte";
98
99 interface Props extends Omit<HTMLAnchorAttributes & HTMLButtonAttributes, "class" | "children"> {
100 variant?: ButtonVariants["variant"];
101 size?: ButtonVariants["size"];
102 insetShadow?: boolean;
103 href?: ResolvedPathname;
104 type?: "button" | "submit" | "reset";
105 disabled?: boolean;
106 icon?: Component<SvelteHTMLElements["svg"]>;
107 iconSide?: string;
108 class?: string;
109 loading?: boolean;
110 spinnerClass?: string;
111 children?: Snippet;
112 }
113
114 let {
115 variant = "default",
116 size = "md",
117 insetShadow = false,
118 href,
119 type = "button",
120 disabled = false,
121 icon,
122 iconSide = "left",
123 class: className,
124 loading = false,
125 spinnerClass,
126 children,
127 ...rest
128 }: Props = $props();
129
130 const classes = $derived(button({ variant, size, insetShadow, class: className }));
131 const spinnerSize = $derived(spinnerClass ?? (size === "lg" ? "size-5" : "size-4"));
132 const iconSize = $derived(size === "lg" ? "size-5" : "size-4");
133 const Icon = $derived(icon);
134</script>
135
136{#snippet content()}
137 {#if loading}
138 <Spinner class={spinnerSize} />
139 {:else}
140 {#if Icon && iconSide == "left"}
141 <Icon class="{iconSize} shrink-0" aria-hidden="true" />
142 {/if}
143 {#if children}
144 {@render children()}
145 {/if}
146 {#if Icon && iconSide == "right"}
147 <Icon class="{iconSize} shrink-0" aria-hidden="true" />
148 {/if}
149 {/if}
150{/snippet}
151
152{#if href}
153 <a
154 {href}
155 class={classes}
156 aria-disabled={disabled || loading ? "true" : undefined}
157 tabindex={disabled ? -1 : undefined}
158 {...rest}
159 >
160 {@render content()}
161 </a>
162{:else}
163 <button {type} disabled={disabled || loading} class={classes} aria-busy={loading} {...rest}>
164 {@render content()}
165 </button>
166{/if}