- confirm.svelte.ts: context-based async service returning Promise<boolean> - ConfirmDialog.svelte: native <dialog> element, reads service from context - Concurrent calls return false immediately (guard at top of confirm()) - SSR-safe: confirm() returns Promise.resolve(false) on server - getConfirmService() throws descriptive error outside provider tree - 5 Vitest tests: confirm/cancel/Escape/concurrent/outside-provider all green Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
101 lines
3.0 KiB
TypeScript
101 lines
3.0 KiB
TypeScript
/**
|
|
* Context-based confirmation service. Provides an async `confirm()` function
|
|
* that any component can call without managing its own modal state.
|
|
*
|
|
* ## Setup
|
|
* Mount `<ConfirmDialog>` once in the root `+layout.svelte` — it sets up the context
|
|
* automatically. Then call `getConfirmService()` from any descendant component.
|
|
*
|
|
* ## Usage in event handlers
|
|
* ```typescript
|
|
* import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
|
* const { confirm } = getConfirmService();
|
|
*
|
|
* async function handleDelete() {
|
|
* const ok = await confirm({ title: m.confirm_delete_title(), destructive: true });
|
|
* if (ok) doDelete();
|
|
* }
|
|
* ```
|
|
*
|
|
* ## Usage with use:enhance
|
|
* ```svelte
|
|
* <form use:enhance={async ({ cancel }) => {
|
|
* const ok = await confirm({ title: m.confirm_delete_title(), destructive: true });
|
|
* if (!ok) cancel();
|
|
* }}>
|
|
* ```
|
|
*/
|
|
import { getContext, setContext } from 'svelte';
|
|
import { browser } from '$app/environment';
|
|
|
|
export const CONFIRM_KEY = Symbol('confirm');
|
|
|
|
export interface ConfirmOptions {
|
|
title: string;
|
|
body?: string;
|
|
/** Defaults to m.btn_confirm() ("Bestätigen") */
|
|
confirmLabel?: string;
|
|
/** Defaults to m.btn_cancel() ("Abbrechen") */
|
|
cancelLabel?: string;
|
|
/** Uses danger color for confirm button. Defaults to false. */
|
|
destructive?: boolean;
|
|
/** Close when clicking outside the dialog. Defaults to !destructive. */
|
|
closeOnBackdrop?: boolean;
|
|
}
|
|
|
|
export interface ConfirmService {
|
|
confirm(opts: ConfirmOptions): Promise<boolean>;
|
|
/** Read by ConfirmDialog to render the current dialog. Internal use only. */
|
|
readonly options: ConfirmOptions | null;
|
|
/** Called by ConfirmDialog when the user makes a choice. Internal use only. */
|
|
settle(value: boolean): void;
|
|
}
|
|
|
|
export function createConfirmService(): ConfirmService {
|
|
let resolveRef: ((value: boolean) => void) | null = $state(null);
|
|
let options: ConfirmOptions | null = $state(null);
|
|
|
|
return {
|
|
confirm(opts: ConfirmOptions): Promise<boolean> {
|
|
if (!browser) return Promise.resolve(false);
|
|
// Concurrent call while dialog is already open — reject immediately.
|
|
if (resolveRef !== null) return Promise.resolve(false);
|
|
options = opts;
|
|
return new Promise((r) => {
|
|
resolveRef = r;
|
|
});
|
|
},
|
|
|
|
get options() {
|
|
return options;
|
|
},
|
|
|
|
settle(value: boolean): void {
|
|
options = null;
|
|
const r = resolveRef;
|
|
resolveRef = null;
|
|
r?.(value);
|
|
}
|
|
};
|
|
}
|
|
|
|
export function provideConfirmService(): ConfirmService {
|
|
const service = createConfirmService();
|
|
setContext(CONFIRM_KEY, service);
|
|
return service;
|
|
}
|
|
|
|
export function getConfirmService(): ConfirmService {
|
|
// Outside component init, getContext either returns undefined or throws a Svelte error.
|
|
// Either way, map it to our descriptive developer error.
|
|
let service: ConfirmService | undefined;
|
|
try {
|
|
service = getContext<ConfirmService>(CONFIRM_KEY);
|
|
} catch {
|
|
throw new Error('ConfirmService not found — mount <ConfirmDialog> in +layout.svelte');
|
|
}
|
|
if (!service)
|
|
throw new Error('ConfirmService not found — mount <ConfirmDialog> in +layout.svelte');
|
|
return service;
|
|
}
|