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.
170 lines
5.2 KiB
Svelte
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>
|