Monorepo for Tangled
tangled.org
1<script lang="ts">
2 import { resolve } from '$app/paths';
3 import type { Snippet } from 'svelte';
4 import type { HTMLAnchorAttributes, HTMLButtonAttributes } from 'svelte/elements';
5 import Spinner from './Spinner.svelte';
6
7 interface Props extends Omit<HTMLAnchorAttributes & HTMLButtonAttributes, 'class' | 'children'> {
8 variant?: 'default' | 'primary' | 'ghost' | 'danger' | 'success';
9 size?: 'sm' | 'md' | 'lg';
10 href?: string;
11 type?: 'button' | 'submit' | 'reset';
12 disabled?: boolean;
13 class?: string;
14 loading?: boolean;
15 spinnerClass?: string;
16 children?: Snippet;
17 }
18
19 let {
20 variant = 'default',
21 size = 'md',
22 href,
23 type = 'button',
24 disabled = false,
25 class: className = '',
26 loading = false,
27 spinnerClass,
28 children,
29 ...rest
30 }: Props = $props();
31
32 const raised = $derived(variant !== 'ghost');
33 const accentRaised = $derived(variant !== 'default' && variant !== 'ghost');
34 const spinnerSize = $derived(spinnerClass ?? (size === 'lg' ? 'size-5' : 'size-4'));
35</script>
36
37{#if href}
38 <a
39 href={resolve(href as '/')}
40 class="btn {className}"
41 data-variant={variant}
42 data-size={size}
43 class:raised
44 class:accentRaised
45 aria-disabled={disabled || loading ? 'true' : undefined}
46 tabindex={disabled ? -1 : undefined}
47 {...rest}
48 >
49 {#if loading}
50 <Spinner class={spinnerSize} />
51 {:else if children}
52 {@render children()}
53 {/if}
54 </a>
55{:else}
56 <button
57 {type}
58 disabled={disabled || loading}
59 class="btn {className}"
60 data-variant={variant}
61 data-size={size}
62 class:raised
63 class:accentRaised
64 aria-busy={loading}
65 {...rest}
66 >
67 {#if loading}
68 <Spinner class={spinnerSize} />
69 {:else if children}
70 {@render children()}
71 {/if}
72 </button>
73{/if}
74
75<style>
76 .btn {
77 display: inline-flex;
78 cursor: pointer;
79 align-items: center;
80 justify-content: center;
81 overflow: hidden;
82 border-radius: 0.25rem;
83 text-decoration: none;
84 transition:
85 background-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
86 border-color 150ms cubic-bezier(0.4, 0, 0.2, 1),
87 color 150ms cubic-bezier(0.4, 0, 0.2, 1);
88 user-select: none;
89 }
90
91 .btn:hover {
92 text-decoration: none;
93 }
94
95 .btn:focus-visible {
96 outline: 2px solid var(--color-ring);
97 outline-offset: 2px;
98 }
99
100 .btn:disabled {
101 pointer-events: none;
102 opacity: 0.5;
103 }
104
105 .btn[data-variant='default'] {
106 background-color: var(--color-surface);
107 border: 1px solid var(--color-border);
108 color: var(--color-fg);
109 }
110 .btn[data-variant='default']:hover {
111 background-color: var(--color-surface-hover);
112 }
113 .btn[data-variant='default']:focus-visible {
114 outline-color: var(--color-ring);
115 }
116
117 .btn[data-variant='primary'] {
118 background-color: var(--color-primary);
119 border: 1px solid var(--color-primary-border);
120 color: var(--color-primary-fg);
121 }
122 .btn[data-variant='primary']:hover {
123 background-color: var(--color-primary-hover);
124 }
125 .btn[data-variant='primary']:focus-visible {
126 outline-color: var(--color-primary);
127 }
128
129 .btn[data-variant='ghost'] {
130 color: var(--color-fg-muted);
131 background-color: transparent;
132 }
133 .btn[data-variant='ghost']:hover {
134 background-color: var(--color-surface-muted);
135 }
136 .btn[data-variant='ghost']:focus-visible {
137 outline-color: var(--color-ring);
138 }
139
140 .btn[data-variant='danger'] {
141 background-color: var(--color-danger);
142 border: 1px solid var(--color-danger-border);
143 color: var(--color-danger-fg);
144 }
145 .btn[data-variant='danger']:hover {
146 background-color: var(--color-danger-hover);
147 }
148 .btn[data-variant='danger']:focus-visible {
149 outline-color: var(--color-danger);
150 }
151
152 .btn[data-variant='success'] {
153 background-color: var(--color-success);
154 border: 1px solid var(--color-success-border);
155 color: var(--color-success-fg);
156 }
157 .btn[data-variant='success']:hover {
158 background-color: var(--color-success-hover);
159 }
160 .btn[data-variant='success']:focus-visible {
161 outline-color: var(--color-success);
162 }
163
164 .btn[data-size='sm'] {
165 min-height: 2rem;
166 padding: 0.375rem 0.5rem;
167 font-size: 0.875rem;
168 gap: 0.375rem;
169 }
170 .btn[data-size='md'] {
171 min-height: 2.25rem;
172 padding: 0.5rem 1rem;
173 font-size: 0.875rem;
174 gap: 0.5rem;
175 }
176 .btn[data-size='lg'] {
177 min-height: 2.5rem;
178 padding: 0.5rem 1.5rem;
179 font-size: 1rem;
180 gap: 0.625rem;
181 }
182
183 .raised {
184 position: relative;
185 z-index: 0;
186 }
187
188 .raised::before {
189 content: '';
190 position: absolute;
191 inset: 0;
192 z-index: -10;
193 border-radius: inherit;
194 box-shadow: inset 0 -2px 0 0 var(--color-raised-depth);
195 transition: all 150ms cubic-bezier(0.4, 0, 0.2, 1);
196 }
197
198 .raised:hover::before {
199 box-shadow: inset 0 -2px 0 0 var(--color-raised-depth-hover);
200 }
201
202 .raised:active {
203 transform: translateY(0.125rem);
204 }
205
206 .raised:active::before {
207 box-shadow: inset 0 2px 2px 0 var(--color-raised-depth);
208 }
209
210 .accentRaised::before,
211 .accentRaised:hover::before {
212 box-shadow: inset 0 -2px 0 0 var(--color-raised-accent-depth);
213 }
214
215 .accentRaised:active::before {
216 box-shadow: inset 0 2px 2px 0 var(--color-raised-accent-depth-active);
217 }
218
219 .raised:disabled:active {
220 transform: none;
221 }
222</style>