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:
@@ -1,21 +1,84 @@
|
||||
<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>
|
||||
|
||||
<div class="flex items-start gap-3 py-2.5">
|
||||
<button
|
||||
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'}"
|
||||
onclick={() => checked = !checked}
|
||||
{checked ? 'bg-accent shadow-[0_0_8px_rgba(201,133,85,0.25)]' : 'bg-bg-elevated'}
|
||||
{disabled && !busy ? 'opacity-50 cursor-not-allowed' : ''}
|
||||
{busy ? 'cursor-wait' : ''}"
|
||||
onclick={handleClick}
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
aria-label={label}
|
||||
aria-disabled={disabled ? "true" : undefined}
|
||||
aria-busy={busy ? "true" : undefined}
|
||||
>
|
||||
<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'}"
|
||||
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>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-[13px] text-text leading-tight">{label}</p>
|
||||
|
||||
Reference in New Issue
Block a user