feat(toggle): add disabled, loading, and async onChange props

Extends the shared Toggle with three optional props while keeping the
existing bind:checked path untouched for current call sites.

- disabled: dims the control, sets aria-disabled, and early-returns on click.
- loading: forces a Loader2 spinner inside the thumb, applies cursor-wait
  and aria-busy, and blocks interaction. Cursor-wait wins over disabled's
  not-allowed when both are set, matching loading-precedence guidance.
- onChange(next): when supplied, replaces the immediate flip. Promise
  returns drive an internal pending flag so the toggle renders loading
  while the handler resolves; on resolve checked flips, on reject the
  toggle snaps back. Synchronous handlers flip immediately after the call.

Brand motion preserved: 150ms duration, cubic-bezier(0.2, 0.8, 0.2, 1),
no transition-all.
This commit is contained in:
2026-05-07 10:06:06 +01:00
parent 5e3a9f9f42
commit aebf6cd149

View File

@@ -1,21 +1,84 @@
<script> <script>
let { checked = $bindable(false), label = "", description = "" } = $props(); import { Loader2 } from "lucide-svelte";
let {
checked = $bindable(false),
label = "",
description = "",
/** @type {boolean} disable interaction and dim the toggle */
disabled = false,
/** @type {boolean} render a spinner in the thumb and block interaction */
loading = $bindable(false),
/** @type {((next: boolean) => void | Promise<void>) | undefined} optional async handler */
onChange = undefined,
} = $props();
// Internal pending flag drives the spinner when an async onChange is in-flight.
// Kept separate from the `loading` prop so callers can also force loading externally.
let pending = $state(false);
// Effective busy state covers both the externally-controlled prop and our internal pending flag.
let busy = $derived(loading || pending);
async function handleClick() {
if (disabled || busy) return;
const next = !checked;
if (onChange) {
let result;
try {
result = onChange(next);
} catch (err) {
// Synchronous throw: leave checked unchanged, surface the error.
throw err;
}
// If the handler returned a thenable, treat it as async and snap back on rejection.
if (result && typeof result.then === "function") {
pending = true;
try {
await result;
checked = next;
} catch (_) {
// Swallow; the toggle simply stays at its previous value.
} finally {
pending = false;
}
} else {
// Synchronous handler: flip immediately after invocation.
checked = next;
}
} else {
// No handler provided: preserve original bind:checked behaviour.
checked = next;
}
}
</script> </script>
<div class="flex items-start gap-3 py-2.5"> <div class="flex items-start gap-3 py-2.5">
<button <button
class="relative mt-0.5 w-[38px] min-w-[38px] h-[22px] rounded-full flex-shrink-0 class="relative mt-0.5 w-[38px] min-w-[38px] h-[22px] rounded-full flex-shrink-0
{checked ? 'bg-accent shadow-[0_0_8px_rgba(201,133,85,0.25)]' : 'bg-bg-elevated'}" {checked ? 'bg-accent shadow-[0_0_8px_rgba(201,133,85,0.25)]' : 'bg-bg-elevated'}
onclick={() => checked = !checked} {disabled && !busy ? 'opacity-50 cursor-not-allowed' : ''}
{busy ? 'cursor-wait' : ''}"
onclick={handleClick}
role="switch" role="switch"
aria-checked={checked} aria-checked={checked}
aria-label={label} aria-label={label}
aria-disabled={disabled ? "true" : undefined}
aria-busy={busy ? "true" : undefined}
> >
<span <span
class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full bg-white shadow-sm class="absolute top-[3px] left-[3px] w-4 h-4 rounded-full shadow-sm flex items-center justify-center
{busy ? '' : 'bg-white'}
{checked ? 'translate-x-[16px]' : 'translate-x-0'}" {checked ? 'translate-x-[16px]' : 'translate-x-0'}"
style="transition: transform var(--duration-ui) cubic-bezier(0.2, 0.8, 0.2, 1)" style="transition: transform var(--duration-ui) cubic-bezier(0.2, 0.8, 0.2, 1)"
></span> >
{#if busy}
<Loader2 class="w-4 h-4 animate-spin text-accent" />
{/if}
</span>
</button> </button>
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-[13px] text-text leading-tight">{label}</p> <p class="text-[13px] text-text leading-tight">{label}</p>