This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / web / src / lib / components / ui / Button.svelte
7.8 kB 191 lines
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 aria-disabled={disabled || loading ? "true" : undefined} 182 tabindex={disabled ? -1 : undefined} 183 {...rest} 184 > 185 {@render content()} 186 </a> 187{:else} 188 <button {type} disabled={disabled || loading} class={classes} aria-busy={loading} {...rest}> 189 {@render content()} 190 </button> 191{/if}