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:
61
frontend/src/lib/components/ConfirmDialog.svelte
Normal file
61
frontend/src/lib/components/ConfirmDialog.svelte
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getConfirmService } from '$lib/services/confirm.svelte.js';
|
||||||
|
import * as m from '$lib/paraglide/messages.js';
|
||||||
|
|
||||||
|
// Context must already be set by the parent layout via provideConfirmService().
|
||||||
|
const service = getConfirmService();
|
||||||
|
|
||||||
|
let dialogEl: HTMLDialogElement;
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (service.options) {
|
||||||
|
dialogEl.showModal();
|
||||||
|
} else {
|
||||||
|
dialogEl.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<dialog
|
||||||
|
bind:this={dialogEl}
|
||||||
|
class="max-w-sm rounded-sm border border-line bg-surface p-6 shadow-lg"
|
||||||
|
aria-labelledby="confirm-title"
|
||||||
|
oncancel={(e) => {
|
||||||
|
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}
|
||||||
|
<h2 id="confirm-title" class="mb-2 font-serif text-lg text-ink">{opts.title}</h2>
|
||||||
|
{#if opts.body !== undefined}
|
||||||
|
<p class="mb-6 text-sm text-ink-2">{opts.body}</p>
|
||||||
|
{/if}
|
||||||
|
<div class="flex items-center justify-end gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="min-h-[44px] rounded-sm border border-line px-4 py-2 text-sm font-medium text-ink-2 transition-colors hover:bg-muted"
|
||||||
|
onclick={() => service.settle(false)}
|
||||||
|
>
|
||||||
|
{opts.cancelLabel ?? m.btn_cancel()}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="min-h-[44px] rounded-sm px-4 py-2 text-sm font-medium transition-colors {opts.destructive
|
||||||
|
? 'bg-danger text-danger-fg'
|
||||||
|
: 'bg-primary text-primary-fg'}"
|
||||||
|
onclick={() => service.settle(true)}
|
||||||
|
>
|
||||||
|
{opts.confirmLabel ?? m.btn_confirm()}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</dialog>
|
||||||
70
frontend/src/lib/services/confirm.svelte.test.ts
Normal file
70
frontend/src/lib/services/confirm.svelte.test.ts
Normal file
@@ -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 <ConfirmDialog>');
|
||||||
|
});
|
||||||
|
});
|
||||||
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;
|
||||||
|
}
|
||||||
11
frontend/src/lib/services/confirm.test-host.svelte
Normal file
11
frontend/src/lib/services/confirm.test-host.svelte
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { provideConfirmService, type ConfirmService } from './confirm.svelte.js';
|
||||||
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
|
||||||
|
let { onReady }: { onReady: (service: ConfirmService) => void } = $props();
|
||||||
|
|
||||||
|
const service = provideConfirmService();
|
||||||
|
onReady(service);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<ConfirmDialog />
|
||||||
Reference in New Issue
Block a user