feat: replace browser confirm() with reusable ConfirmDialog component

Promise-based modal dialog with backdrop, keyboard support, and animations,
replacing all 18 native confirm() call sites across fitness, cospend, recipes,
and tasks pages.
This commit is contained in:
2026-04-08 16:47:21 +02:00
parent 7fb47717f4
commit 376fbf1ba7
20 changed files with 206 additions and 25 deletions
+108
View File
@@ -0,0 +1,108 @@
<script>
import { getConfirmDialog } from '$lib/js/confirmDialog.svelte';
const dialog = getConfirmDialog();
function onKeydown(e) {
if (!dialog.open) return;
if (e.key === 'Escape') dialog.respond(false);
if (e.key === 'Enter') dialog.respond(true);
}
</script>
<svelte:window onkeydown={onKeydown} />
{#if dialog.open}
<div class="confirm-backdrop" onclick={() => dialog.respond(false)} role="presentation">
<div class="confirm-dialog" onclick={(e) => e.stopPropagation()} role="alertdialog" aria-modal="true">
{#if dialog.title}
<h3 class="confirm-title">{dialog.title}</h3>
{/if}
<p class="confirm-message">{dialog.message}</p>
<div class="confirm-actions">
<button class="confirm-btn cancel" onclick={() => dialog.respond(false)}>
{dialog.cancelText}
</button>
<button
class="confirm-btn confirm"
class:destructive={dialog.destructive}
onclick={() => dialog.respond(true)}
>
{dialog.confirmText}
</button>
</div>
</div>
</div>
{/if}
<style>
.confirm-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
animation: fade-in 150ms ease-out;
}
.confirm-dialog {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
padding: 1.25rem 1.5rem;
max-width: 360px;
width: calc(100vw - 2rem);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
animation: scale-in 150ms ease-out;
}
.confirm-title {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 700;
color: var(--color-text-primary);
}
.confirm-message {
margin: 0 0 1.25rem;
font-size: 0.85rem;
line-height: 1.5;
color: var(--color-text-secondary);
}
.confirm-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.confirm-btn {
padding: 0.45rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
border: none;
transition: opacity 150ms;
}
.confirm-btn:hover {
opacity: 0.85;
}
.confirm-btn.cancel {
background: var(--color-bg-tertiary);
color: var(--color-text-primary);
}
.confirm-btn.confirm {
background: var(--color-primary);
color: var(--color-text-on-primary);
}
.confirm-btn.confirm.destructive {
background: var(--nord11);
color: white;
}
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes scale-in {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
</style>
@@ -7,6 +7,7 @@
import { getCategoryEmoji } from '$lib/utils/categories';
import { formatCurrency as formatCurrencyUtil } from '$lib/utils/formatters';
import { detectCospendLang, cospendRoot, t, locale, splitDescription, paymentCategoryName } from '$lib/js/cospendI18n';
import { confirm } from '$lib/js/confirmDialog.svelte';
let { paymentId, onclose, onpaymentDeleted } = $props();
@@ -110,7 +111,7 @@
let deleting = $state(false);
async function deletePayment() {
if (!confirm(t('delete_payment_confirm', lang))) {
if (!await confirm(t('delete_payment_confirm', lang))) {
return;
}
@@ -2,6 +2,7 @@
import { t } from '$lib/js/fitnessI18n';
import { Trash2, Plus, Pencil, UserPlus, X, ChevronLeft, ChevronRight } from '@lucide/svelte';
import { toast } from '$lib/js/toast.svelte';
import { confirm } from '$lib/js/confirmDialog.svelte';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
/**
@@ -523,7 +524,7 @@
/** @param {string} id */
async function deletePeriod(id) {
if (!confirm(t('delete_period_confirm', lang))) return;
if (!await confirm(t('delete_period_confirm', lang))) return;
try {
const res = await fetch(`/api/fitness/period/${id}`, { method: 'DELETE' });
if (res.ok) {
@@ -7,6 +7,7 @@ import Plus from '$lib/assets/icons/Plus.svelte'
import Check from '$lib/assets/icons/Check.svelte'
import "$lib/css/action_button.css"
import { confirm } from '$lib/js/confirmDialog.svelte'
import { do_on_key } from '$lib/components/recipes/do_on_key.js'
import { portions } from '$lib/js/portions_store.js'
@@ -138,8 +139,8 @@ function handleSelect(recipe: any, options: any) {
showSelector = false;
}
export function removeReference(list_index: number) {
const confirmed = confirm(t[lang].confirmDeleteReference);
export async function removeReference(list_index: number) {
const confirmed = await confirm(t[lang].confirmDeleteReference);
if (confirmed) {
ingredients.splice(list_index, 1);
ingredients = ingredients;
@@ -265,9 +266,9 @@ export function add_new_ingredient(){
ingredients[list_index].list.push({ ...new_ingredient})
ingredients = ingredients //tells svelte to update dom
}
export function remove_list(list_index: number){
export async function remove_list(list_index: number){
if(ingredients[list_index].list.length > 1){
const response = confirm(t[lang].confirmDeleteList);
const response = await confirm(t[lang].confirmDeleteList);
if(!response){
return
}
@@ -8,6 +8,7 @@ import Check from '$lib/assets/icons/Check.svelte'
import "$lib/css/action_button.css"
import { do_on_key } from '$lib/components/recipes/do_on_key.js'
import { confirm } from '$lib/js/confirmDialog.svelte'
import BaseRecipeSelector from '$lib/components/recipes/BaseRecipeSelector.svelte'
let { lang = 'de' as 'de' | 'en', instructions = $bindable(), add_info = $bindable() } = $props<{ lang?: 'de' | 'en', instructions: any, add_info: any }>();
@@ -134,8 +135,8 @@ function handleSelect(recipe: any, options: any) {
showSelector = false;
}
export function removeReference(list_index: number) {
const confirmed = confirm(t[lang].confirmDeleteReference);
export async function removeReference(list_index: number) {
const confirmed = await confirm(t[lang].confirmDeleteReference);
if (confirmed) {
instructions.splice(list_index, 1);
instructions = instructions;
@@ -219,9 +220,9 @@ function get_sublist_index(sublist_name: string, list: any[]){
}
return -1
}
export function remove_list(list_index: number){
export async function remove_list(list_index: number){
if(instructions[list_index].steps.length > 1){
const response = confirm(t[lang].confirmDeleteList);
const response = await confirm(t[lang].confirmDeleteList);
if(!response){
return
}
+55
View File
@@ -0,0 +1,55 @@
interface ConfirmState {
open: boolean;
title: string;
message: string;
confirmText: string;
cancelText: string;
destructive: boolean;
resolve: ((value: boolean) => void) | null;
}
let state = $state<ConfirmState>({
open: false,
title: '',
message: '',
confirmText: 'OK',
cancelText: 'Cancel',
destructive: false,
resolve: null,
});
export function getConfirmDialog() {
return {
get open() { return state.open; },
get title() { return state.title; },
get message() { return state.message; },
get confirmText() { return state.confirmText; },
get cancelText() { return state.cancelText; },
get destructive() { return state.destructive; },
respond(value: boolean) {
state.resolve?.(value);
state = { ...state, open: false, resolve: null };
}
};
}
interface ConfirmOptions {
title?: string;
confirmText?: string;
cancelText?: string;
destructive?: boolean;
}
export function confirm(message: string, options: ConfirmOptions = {}): Promise<boolean> {
return new Promise((resolve) => {
state = {
open: true,
message,
title: options.title ?? '',
confirmText: options.confirmText ?? 'OK',
cancelText: options.cancelText ?? 'Cancel',
destructive: options.destructive ?? true,
resolve,
};
});
}