feat(confirm): add ConfirmService and ConfirmDialog with deferred-Promise pattern
- 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>
This commit is contained in:
100
frontend/src/lib/services/confirm.svelte.ts
Normal file
100
frontend/src/lib/services/confirm.svelte.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
Reference in New Issue
Block a user