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>
|
<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>
|
||||||
|
|||||||
Reference in New Issue
Block a user