feat(common): generic Promise-based ConfirmDialog primitive (#640 part C)
Ports 8digit/picpeak@88bfde1 — replaces `window.confirm()` with a styled, themed, accessible in-app modal. Usage: const confirm = useConfirm(); const ok = await confirm({ title: 'Delete event?', message: 'This will permanently remove the gallery and all photos.', variant: 'danger', confirmLabel: 'Delete', }); if (ok) doDelete(); Three variants: 'primary' (default, no icon), 'danger' (red AlertCircle + red confirm button), 'warning' (amber AlertTriangle). Keyboard support: Escape cancels, Enter confirms (unless focus is in an input/textarea/select so an open form doesn't get hijacked), backdrop click cancels. Cancel button is focused by default — a stray Enter cannot accidentally confirm a destructive action. Wraps at App.tsx level, inside GlobalThemeProvider so the modal respects the theme tokens, above the toast container so a confirm appearing under a toast still gets the click. Provider exports through components/common alongside the rest of the shared primitives. This PR only lands the primitive. Existing window.confirm() call-sites are left untouched — sweeping them is follow-up work that can land in any cadence (each sweep is one component, no architectural risk). Existing structured-input flows (PublishGalleryDialog, DuplicateEventDialog, PasswordResetModal, etc.) stay as-is — they collect data, not yes/no. No new i18n entries — uses common.cancel / common.confirm / common.close which already exist in EN + DE. ### Test plan - [x] tsc --noEmit clean - [x] eslint clean on changed files - [ ] Manual: pick any existing window.confirm() site (e.g. EventDetailsPage delete button), swap to useConfirm(), verify the modal renders with theme tokens, Escape cancels, Enter confirms, backdrop click cancels, focus lands on Cancel - [ ] Manual: variant='danger' renders red confirm button + AlertCircle icon - [ ] Manual: open the dialog from inside another modal (e.g. a settings panel) — z-[9999] keeps the confirm on top of any other overlay
This commit is contained in:
@@ -73,6 +73,7 @@ import { RequireFeature } from './components/admin/RequireFeature';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags, CMSContentBlock, Loading } from './components/common';
|
||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
||||
import { ConfirmDialogProvider } from './components/common';
|
||||
import { usePublicSettings } from './hooks/usePublicSettings';
|
||||
|
||||
// Create a client
|
||||
@@ -149,6 +150,7 @@ function App() {
|
||||
<MaintenanceProvider>
|
||||
<ThemeProvider>
|
||||
<GlobalThemeProvider>
|
||||
<ConfirmDialogProvider>
|
||||
<DynamicFavicon />
|
||||
<RobotsMetaTags />
|
||||
<Router>
|
||||
@@ -406,6 +408,7 @@ function App() {
|
||||
pauseOnHover
|
||||
theme={toastTheme}
|
||||
/>
|
||||
</ConfirmDialogProvider>
|
||||
</GlobalThemeProvider>
|
||||
</ThemeProvider>
|
||||
</MaintenanceProvider>
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertCircle, AlertTriangle, X } from 'lucide-react';
|
||||
import { Button } from './Button';
|
||||
import { Card } from './Card';
|
||||
|
||||
/**
|
||||
* Promise-based confirm dialog (#640 part C, ported from 8digit/picpeak@88bfde1).
|
||||
*
|
||||
* Replaces `window.confirm()` with a styled, themed, accessible in-app modal.
|
||||
* Usage:
|
||||
*
|
||||
* const confirm = useConfirm();
|
||||
* const ok = await confirm({
|
||||
* title: 'Delete event?',
|
||||
* message: 'This will permanently remove the gallery and all photos.',
|
||||
* variant: 'danger',
|
||||
* confirmLabel: 'Delete',
|
||||
* });
|
||||
* if (ok) doDelete();
|
||||
*
|
||||
* Wraps once at the App level via <ConfirmDialogProvider />; every component
|
||||
* below it gets `useConfirm()` for free. Variants:
|
||||
* - 'primary' (default) — plain confirm, no icon
|
||||
* - 'danger' — red AlertCircle, red confirm button
|
||||
* - 'warning' — amber AlertTriangle
|
||||
*
|
||||
* Keyboard: Escape cancels, Enter confirms, backdrop click cancels. The cancel
|
||||
* button is focused by default so a stray Enter doesn't accidentally confirm a
|
||||
* destructive action.
|
||||
*
|
||||
* This is the generic primitive. Existing inline-modal flows (PublishGalleryDialog,
|
||||
* DuplicateEventDialog, PasswordResetModal, etc.) stay as-is — they collect
|
||||
* structured input, not a simple yes/no. Call-site sweeps of `window.confirm()`
|
||||
* follow in later PRs.
|
||||
*/
|
||||
|
||||
export type ConfirmVariant = 'primary' | 'danger' | 'warning';
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title?: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
variant?: ConfirmVariant;
|
||||
}
|
||||
|
||||
type Resolver = (value: boolean) => void;
|
||||
|
||||
interface ConfirmContextValue {
|
||||
confirm: (options: ConfirmOptions) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const ConfirmContext = createContext<ConfirmContextValue | null>(null);
|
||||
|
||||
export const useConfirm = (): ((options: ConfirmOptions) => Promise<boolean>) => {
|
||||
const ctx = useContext(ConfirmContext);
|
||||
if (!ctx) {
|
||||
throw new Error('useConfirm must be used within a ConfirmDialogProvider');
|
||||
}
|
||||
return ctx.confirm;
|
||||
};
|
||||
|
||||
export const ConfirmDialogProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const { t } = useTranslation();
|
||||
const [options, setOptions] = useState<ConfirmOptions | null>(null);
|
||||
const resolverRef = useRef<Resolver | null>(null);
|
||||
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const confirm = useCallback((opts: ConfirmOptions): Promise<boolean> => {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
// If a prior confirm is still open (shouldn't happen in practice but
|
||||
// guard anyway), resolve it as cancelled before opening the new one.
|
||||
if (resolverRef.current) {
|
||||
resolverRef.current(false);
|
||||
}
|
||||
resolverRef.current = resolve;
|
||||
setOptions(opts);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const settle = useCallback((value: boolean) => {
|
||||
if (resolverRef.current) {
|
||||
resolverRef.current(value);
|
||||
resolverRef.current = null;
|
||||
}
|
||||
setOptions(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!options) return;
|
||||
cancelButtonRef.current?.focus();
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
settle(false);
|
||||
} else if (e.key === 'Enter') {
|
||||
// Don't hijack Enter when the focus is in an editable element — covers
|
||||
// the (unusual) case where a confirm is open over an open input.
|
||||
const tag = (document.activeElement as HTMLElement | null)?.tagName;
|
||||
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
|
||||
e.preventDefault();
|
||||
settle(true);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [options, settle]);
|
||||
|
||||
const variant = options?.variant ?? 'primary';
|
||||
const Icon = variant === 'danger' ? AlertCircle : variant === 'warning' ? AlertTriangle : null;
|
||||
const iconClass =
|
||||
variant === 'danger'
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: variant === 'warning'
|
||||
? 'text-amber-600 dark:text-amber-400'
|
||||
: '';
|
||||
|
||||
// Danger uses the outline button + an inline red override so the visual
|
||||
// weight matches the action without redefining a Button variant for one case.
|
||||
const confirmButtonVariant: 'primary' | 'outline' = variant === 'danger' ? 'outline' : 'primary';
|
||||
const confirmButtonClass = variant === 'danger'
|
||||
? 'bg-red-600 hover:bg-red-700 text-white border-red-600'
|
||||
: '';
|
||||
|
||||
return (
|
||||
<ConfirmContext.Provider value={{ confirm }}>
|
||||
{children}
|
||||
{options && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 flex items-center justify-center z-[9999] p-4"
|
||||
onClick={() => settle(false)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<Card
|
||||
className="max-w-md w-full"
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start gap-3 mb-4">
|
||||
{Icon && <Icon className={`w-6 h-6 flex-shrink-0 mt-0.5 ${iconClass}`} />}
|
||||
<div className="flex-1 min-w-0">
|
||||
{options.title && (
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1">
|
||||
{options.title}
|
||||
</h2>
|
||||
)}
|
||||
<p className="text-sm text-neutral-700 dark:text-neutral-300 whitespace-pre-line break-words">
|
||||
{options.message}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => settle(false)}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
ref={cancelButtonRef}
|
||||
variant="outline"
|
||||
onClick={() => settle(false)}
|
||||
>
|
||||
{options.cancelLabel ?? t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirmButtonVariant}
|
||||
onClick={() => settle(true)}
|
||||
className={confirmButtonClass}
|
||||
>
|
||||
{options.confirmLabel ?? t('common.confirm', 'Confirm')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</ConfirmContext.Provider>
|
||||
);
|
||||
};
|
||||
@@ -29,3 +29,4 @@ export { ProtectionWarning } from './ProtectionWarning';
|
||||
export { ReCaptcha } from './ReCaptcha';
|
||||
export { PasswordGenerator } from './PasswordGenerator';
|
||||
export { MarkdownContent } from './MarkdownContent';
|
||||
export { ConfirmDialogProvider, useConfirm, type ConfirmOptions, type ConfirmVariant } from './ConfirmDialog';
|
||||
|
||||
Reference in New Issue
Block a user