feat(confirm): centralize confirmation dialogs and add i18n
Introduce a generic ConfirmDialog and a promise-based requestConfirmation API in App.svelte so callers can await user responses instead of using window.confirm. Provide helper builders (branchDeleteConfirmRequest, discardConfirmRequest) to create dialog content for common cases. Many call sites were switched to use requestConfirmation and now render the in-app ConfirmDialog; the previous specialized confirm components (BranchDeleteConfirmDialog, DiscardConfirmDialog) were removed. Add lightweight i18n support (setLanguage, t()) and new messages/i18n modules, and replace hardcoded English strings in several components (e.g. AiSettingsPage, BlameDialog and many confirmation prompts) with translated keys. Summary of effects: - Replaces native window.confirm with awaitable in-app ConfirmDialog dialogs. - Centralizes confirmation UI and content construction in App.svelte. - Adds i18n plumbing and updates UI text to use t(). - Removes two specialized confirm dialog components and adds src/lib/components/ConfirmDialog.svelte.
This commit is contained in:
@@ -0,0 +1,224 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Generic confirmation dialog. Replaces window.confirm so confirmations use
|
||||
* the app's own styling, translation and focus handling instead of a native,
|
||||
* untranslated, event-blocking browser dialog.
|
||||
*/
|
||||
import { AlertTriangle, Check, LoaderCircle, Trash2, X } from "@lucide/svelte";
|
||||
import { t } from "../i18n.svelte";
|
||||
|
||||
export interface ConfirmRequest {
|
||||
/** Small label above the title. */
|
||||
eyebrow?: string;
|
||||
title: string;
|
||||
/** Leading sentence explaining what happens. */
|
||||
message: string;
|
||||
/** Items the action applies to, rendered as a scrollable list. */
|
||||
items?: string[];
|
||||
/** Extra warning below the list. */
|
||||
note?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
/** Optional opt-in the user must tick before confirming, e.g. "delete anyway". */
|
||||
checkbox?: { label: string; note?: string; required?: boolean };
|
||||
/** Destructive actions get the red confirm button and warning icon. */
|
||||
danger?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
request: ConfirmRequest;
|
||||
isBusy?: boolean;
|
||||
/** `checked` is the state of the optional checkbox. */
|
||||
onConfirm: (checked: boolean) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
let { request, isBusy = false, onConfirm, onCancel }: Props = $props();
|
||||
|
||||
const MAX_VISIBLE_ITEMS = 8;
|
||||
|
||||
let dialogElement = $state<HTMLElement | null>(null);
|
||||
let confirmButton = $state<HTMLButtonElement | null>(null);
|
||||
let danger = $derived(request.danger !== false);
|
||||
let items = $derived(request.items ?? []);
|
||||
let checked = $state(false);
|
||||
let blocked = $derived(Boolean(request.checkbox?.required) && !checked);
|
||||
|
||||
$effect(() => {
|
||||
// Reset the opt-in whenever a different confirmation is shown.
|
||||
request.title;
|
||||
checked = false;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
confirmButton?.focus();
|
||||
});
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
event.stopPropagation();
|
||||
if (!isBusy) onCancel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab" || !dialogElement) return;
|
||||
|
||||
const focusable = [...dialogElement.querySelectorAll<HTMLElement>("button:not(:disabled)")];
|
||||
if (focusable.length === 0) return;
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<div class="dialog-backdrop" role="presentation">
|
||||
<div bind:this={dialogElement} class:danger class="dialog confirm-dialog" role="dialog" aria-modal="true" aria-labelledby="confirm-dialog-title">
|
||||
<header class="dialog-header unified-dialog-header">
|
||||
<span class="unified-dialog-icon" aria-hidden="true">
|
||||
{#if danger}<Trash2 size={18} />{:else}<Check size={18} />{/if}
|
||||
</span>
|
||||
<div class="unified-dialog-text">
|
||||
<span class="eyebrow">{request.eyebrow ?? t("confirm.eyebrow")}</span>
|
||||
<p class="dialog-title" id="confirm-dialog-title">{request.title}</p>
|
||||
</div>
|
||||
<button class="btn-sm dialog-close" type="button" onclick={onCancel} disabled={isBusy} aria-label={t("common.close")}>
|
||||
<X size={16} aria-hidden="true" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="discard-confirm-body">
|
||||
<div class="discard-warning-icon" aria-hidden="true">
|
||||
<AlertTriangle size={22} />
|
||||
</div>
|
||||
|
||||
<div class="discard-confirm-copy">
|
||||
<p class="confirm-lead">{request.message}</p>
|
||||
|
||||
{#if items.length > 0}
|
||||
<ul class="discard-target-list">
|
||||
{#each items.slice(0, MAX_VISIBLE_ITEMS) as item (item)}
|
||||
<li><code class="discard-target" title={item}>{item}</code></li>
|
||||
{/each}
|
||||
{#if items.length > MAX_VISIBLE_ITEMS}
|
||||
<li class="discard-target-more">{items.length - MAX_VISIBLE_ITEMS === 1 ? t("confirm.moreOne") : t("confirm.more", { count: items.length - MAX_VISIBLE_ITEMS })}</li>
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if request.checkbox}
|
||||
<label class="confirm-check">
|
||||
<input type="checkbox" bind:checked disabled={isBusy} />
|
||||
<span>
|
||||
<strong>{request.checkbox.label}</strong>
|
||||
{#if request.checkbox.note}<small>{request.checkbox.note}</small>{/if}
|
||||
</span>
|
||||
</label>
|
||||
{/if}
|
||||
|
||||
{#if request.note}
|
||||
<p class="discard-warning-text">{request.note}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="discard-confirm-actions">
|
||||
<button class="btn-secondary" type="button" onclick={onCancel} disabled={isBusy}>
|
||||
{request.cancelLabel ?? t("common.cancel")}
|
||||
</button>
|
||||
<button
|
||||
bind:this={confirmButton}
|
||||
class={`confirm-action ${danger ? "btn-danger" : "btn-primary"}`}
|
||||
type="button"
|
||||
onclick={() => onConfirm(checked)}
|
||||
disabled={isBusy || blocked}
|
||||
>
|
||||
{#if isBusy}
|
||||
<LoaderCircle class="spin" size={15} aria-hidden="true" />
|
||||
{:else if danger}
|
||||
<Trash2 size={15} aria-hidden="true" />
|
||||
{:else}
|
||||
<Check size={15} aria-hidden="true" />
|
||||
{/if}
|
||||
{request.confirmLabel ?? (danger ? t("common.delete") : t("common.confirm"))}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
/* Matches .discard-confirm-dialog / .branch-delete-dialog so every confirmation
|
||||
in the app has the same size, chrome and rhythm. */
|
||||
.confirm-dialog {
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
width: min(500px, calc(100vw - 32px));
|
||||
height: auto;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
}
|
||||
.confirm-dialog.danger {
|
||||
border-color: rgba(255, 90, 103, 0.22);
|
||||
box-shadow: var(--app-dialog-shadow), 0 0 0 1px rgba(255, 90, 103, 0.04);
|
||||
}
|
||||
.confirm-dialog.danger .dialog-header {
|
||||
background:
|
||||
linear-gradient(90deg, rgba(255, 90, 103, 0.08), transparent 42%),
|
||||
var(--app-dialog-chrome);
|
||||
}
|
||||
.confirm-dialog .discard-confirm-body { padding: 20px 18px 18px; }
|
||||
.confirm-dialog .confirm-lead {
|
||||
color: var(--color-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
.confirm-dialog.danger .unified-dialog-icon {
|
||||
border-color: rgba(255, 90, 103, 0.28);
|
||||
color: #ff9aa4;
|
||||
background: rgba(255, 90, 103, 0.09);
|
||||
}
|
||||
.confirm-dialog .discard-target-list { max-height: 148px; }
|
||||
.confirm-dialog .discard-warning-text {
|
||||
padding: 9px 10px;
|
||||
border-left: 2px solid rgba(255, 90, 103, 0.55);
|
||||
color: #f2aeb5;
|
||||
background: rgba(255, 90, 103, 0.055);
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.confirm-dialog:not(.danger) .discard-warning-text {
|
||||
border-left-color: color-mix(in srgb, var(--color-accent) 55%, transparent);
|
||||
color: var(--color-ink-muted);
|
||||
background: color-mix(in srgb, var(--color-accent) 7%, transparent);
|
||||
}
|
||||
.confirm-dialog:not(.danger) .discard-warning-icon {
|
||||
border-color: color-mix(in srgb, var(--color-accent) 28%, var(--color-border));
|
||||
color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 9%, transparent);
|
||||
}
|
||||
.confirm-dialog .confirm-action { min-width: 116px; }
|
||||
.confirm-dialog .confirm-check {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 9px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgba(255, 90, 103, 0.28);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 90, 103, 0.05);
|
||||
cursor: pointer;
|
||||
}
|
||||
.confirm-dialog .confirm-check input { width: 15px; height: 15px; margin-top: 1px; accent-color: #e86060; }
|
||||
.confirm-dialog .confirm-check span { display: grid; gap: 2px; min-width: 0; }
|
||||
.confirm-dialog .confirm-check strong { color: var(--color-ink); font-size: 12.5px; font-weight: 650; }
|
||||
.confirm-dialog .confirm-check small { color: var(--color-ink-dim); font-size: 11.5px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user