Files
electric-cherry/src/lib/ui/components/primitives/Button.svelte
T
josh 8ac420fe61 Improve operational UX, recovery flows, and action safety
Implement the approved UX review across Fleet, Betas, Library, deployments, and Settings. Add reviewed promotion and reconciliation plans, atomic queued-removal cancellation, recoverable beta drafts and launch inputs, accessible controls, and mobile improvements.

Validated with 473 tests, a clean typecheck, production build, and isolated desktop/mobile browser walkthroughs. Include the review and implementation coverage notes.
2026-09-19 21:34:34 -04:00

170 lines
5.2 KiB
Svelte

<script lang="ts">
import type { Snippet } from 'svelte';
type Variant = 'primary' | 'secondary' | 'ghost' | 'danger';
type Size = 'sm' | 'md' | 'lg';
interface Props {
variant?: Variant;
size?: Size;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
loading?: boolean;
href?: string | null;
title?: string;
ariaLabel?: string;
onclick?: (e: MouseEvent) => void;
children?: Snippet;
leading?: Snippet;
trailing?: Snippet;
}
let {
variant = 'secondary',
size = 'md',
type = 'button',
disabled = false,
loading = false,
href = null,
title,
ariaLabel,
onclick,
children,
leading,
trailing
}: Props = $props();
let isLink = $derived(href !== null && href !== undefined);
</script>
{#if isLink}
<a
class="btn v-{variant} s-{size}"
class:is-loading={loading}
aria-disabled={disabled || loading}
aria-label={ariaLabel}
{title}
href={disabled || loading ? undefined : href}
tabindex={disabled || loading ? -1 : undefined}
aria-busy={loading}
onclick={(event) => { if (disabled || loading) event.preventDefault(); else onclick?.(event); }}
>
{#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if}
<span class="label">{@render children?.()}</span>
{#if trailing}<span class="slot slot-trailing">{@render trailing()}</span>{/if}
{#if loading}<span class="spinner" aria-hidden="true"></span>{/if}
</a>
{:else}
<button
class="btn v-{variant} s-{size}"
class:is-loading={loading}
{type}
{title}
aria-label={ariaLabel}
aria-busy={loading}
disabled={disabled || loading}
{onclick}
>
{#if leading}<span class="slot slot-leading">{@render leading()}</span>{/if}
<span class="label">{@render children?.()}</span>
{#if trailing}<span class="slot slot-trailing">{@render trailing()}</span>{/if}
{#if loading}<span class="spinner" aria-hidden="true"></span>{/if}
</button>
{/if}
<style>
.btn {
max-width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
border-radius: var(--radius-md);
font-family: var(--font-body);
font-weight: var(--weight-semibold);
letter-spacing: var(--tracking-normal);
line-height: 1.35;
min-height: 40px;
cursor: pointer;
text-decoration: none;
border: 1px solid transparent;
transition:
background-color var(--dur-fast) var(--ease-standard),
border-color var(--dur-fast) var(--ease-standard),
color var(--dur-fast) var(--ease-standard),
transform var(--dur-fast) var(--ease-standard),
box-shadow var(--dur-fast) var(--ease-standard);
white-space: nowrap;
user-select: none;
position: relative;
}
.btn:focus { outline: none; }
.btn:focus-visible { box-shadow: var(--focus-ring); }
.btn:disabled,
.btn[aria-disabled='true'] {
opacity: 0.5;
cursor: not-allowed;
}
.btn:active:not(:disabled):not([aria-disabled='true']) { transform: translateY(0.5px); }
/* Sizes */
.s-sm { min-height: 32px; padding: 6px 12px; font-size: var(--text-xs); border-radius: var(--radius-sm); }
.s-md { padding: 9px 16px; font-size: var(--text-sm); }
.s-lg { padding: 12px 22px; font-size: var(--text-base); }
/* Variants */
.v-primary {
background: var(--accent-primary);
color: var(--text-on-accent);
}
.v-primary:hover:not(:disabled):not([aria-disabled='true']) {
background: var(--accent-primary-hover);
color: var(--gray-950);
}
.v-secondary {
background: var(--surface-raised);
color: var(--text-primary);
border-color: var(--border-subtle);
}
.v-secondary:hover:not(:disabled):not([aria-disabled='true']) {
background: var(--surface-overlay);
border-color: var(--border-strong);
}
.v-ghost {
background: transparent;
color: var(--text-secondary);
}
.v-ghost:hover:not(:disabled):not([aria-disabled='true']) {
background: var(--surface-raised);
color: var(--text-primary);
}
.v-danger {
background: transparent;
color: var(--state-danger);
border-color: rgba(255, 86, 112, 0.32);
}
.v-danger:hover:not(:disabled):not([aria-disabled='true']) {
background: rgba(255, 86, 112, 0.12);
border-color: var(--state-danger);
}
/* Loading */
.is-loading .label,
.is-loading .slot { opacity: 0; }
.spinner {
position: absolute;
width: 14px; height: 14px;
border-radius: 50%;
border: 2px solid currentColor;
border-top-color: transparent;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (max-width: 720px) {
.btn { white-space: normal; line-height: 1.35; }
}
</style>