diff --git a/frontend/src/lib/components/ConfirmDialog.svelte b/frontend/src/lib/components/ConfirmDialog.svelte new file mode 100644 index 00000000..09d5af19 --- /dev/null +++ b/frontend/src/lib/components/ConfirmDialog.svelte @@ -0,0 +1,61 @@ + + + { + e.preventDefault(); + service.settle(false); + }} + onclick={(e) => { + const opts = service.options; + if (!opts) return; + const closeOnBackdrop = opts.closeOnBackdrop ?? !opts.destructive; + if (closeOnBackdrop && e.target === dialogEl) { + service.settle(false); + } + }} +> + {#if service.options} + {@const opts = service.options} +

{opts.title}

+ {#if opts.body !== undefined} +

{opts.body}

+ {/if} +
+ + +
+ {/if} +
diff --git a/frontend/src/lib/services/confirm.svelte.test.ts b/frontend/src/lib/services/confirm.svelte.test.ts new file mode 100644 index 00000000..a1449b96 --- /dev/null +++ b/frontend/src/lib/services/confirm.svelte.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, afterEach } from 'vitest'; +import { cleanup, render } from 'vitest-browser-svelte'; +import { page, userEvent } from 'vitest/browser'; +import TestHost from './confirm.test-host.svelte'; +import type { ConfirmService } from './confirm.svelte.js'; + +afterEach(cleanup); + +function makeHost(): { service: ConfirmService } { + const result: { service: ConfirmService | null } = { service: null }; + render(TestHost, { + onReady: (s: ConfirmService) => { + result.service = s; + } + }); + return result as { service: ConfirmService }; +} + +describe('ConfirmService', () => { + it('resolves true when the user clicks Confirm', async () => { + const { service } = makeHost(); + + const resultPromise = service.confirm({ title: 'Test?' }); + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + await page.getByRole('button', { name: 'Bestätigen' }).click(); + + expect(await resultPromise).toBe(true); + }); + + it('resolves false when the user clicks Cancel', async () => { + const { service } = makeHost(); + + const resultPromise = service.confirm({ title: 'Test?' }); + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + await page.getByRole('button', { name: 'Abbrechen' }).click(); + + expect(await resultPromise).toBe(false); + }); + + it('resolves false when Escape is pressed', async () => { + const { service } = makeHost(); + + const resultPromise = service.confirm({ title: 'Test?' }); + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + await userEvent.keyboard('{Escape}'); + + expect(await resultPromise).toBe(false); + }); + + it('resolves false immediately on a concurrent call while dialog is open', async () => { + const { service } = makeHost(); + + const first = service.confirm({ title: 'First?' }); + await expect.element(page.getByRole('dialog')).toBeInTheDocument(); + + const second = service.confirm({ title: 'Second?' }); + expect(await second).toBe(false); + + // clean up the first dialog + await page.getByRole('button', { name: 'Abbrechen' }).click(); + expect(await first).toBe(false); + }); + + it('throws a descriptive error when called outside provider tree', async () => { + const { getConfirmService } = await import('./confirm.svelte.js'); + // Outside component init, getContext throws or returns undefined — our guard + // converts either case to a descriptive developer error + expect(() => getConfirmService()).toThrow('mount '); + }); +}); diff --git a/frontend/src/lib/services/confirm.svelte.ts b/frontend/src/lib/services/confirm.svelte.ts new file mode 100644 index 00000000..28bbf435 --- /dev/null +++ b/frontend/src/lib/services/confirm.svelte.ts @@ -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 `` 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 + *
{ + * 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; + /** 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 { + 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(CONFIRM_KEY); + } catch { + throw new Error('ConfirmService not found — mount in +layout.svelte'); + } + if (!service) + throw new Error('ConfirmService not found — mount in +layout.svelte'); + return service; +} diff --git a/frontend/src/lib/services/confirm.test-host.svelte b/frontend/src/lib/services/confirm.test-host.svelte new file mode 100644 index 00000000..0d6a8406 --- /dev/null +++ b/frontend/src/lib/services/confirm.test-host.svelte @@ -0,0 +1,11 @@ + + +