From a3fcb5bc9e82849ebe1f55620e8aa7e60ccd973f Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Thu, 18 Jun 2026 22:36:40 +0200 Subject: [PATCH] feat(common): generic Promise-based ConfirmDialog primitive (#640 part C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/App.tsx | 3 + .../src/components/common/ConfirmDialog.tsx | 182 ++++++++++++++++++ frontend/src/components/common/index.ts | 1 + 3 files changed, 186 insertions(+) create mode 100644 frontend/src/components/common/ConfirmDialog.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 95cd90cb..7fd92407 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { + @@ -406,6 +408,7 @@ function App() { pauseOnHover theme={toastTheme} /> + diff --git a/frontend/src/components/common/ConfirmDialog.tsx b/frontend/src/components/common/ConfirmDialog.tsx new file mode 100644 index 00000000..4530d8bd --- /dev/null +++ b/frontend/src/components/common/ConfirmDialog.tsx @@ -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 ; 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; +} + +const ConfirmContext = createContext(null); + +export const useConfirm = (): ((options: ConfirmOptions) => Promise) => { + 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(null); + const resolverRef = useRef(null); + const cancelButtonRef = useRef(null); + + const confirm = useCallback((opts: ConfirmOptions): Promise => { + return new Promise((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 ( + + {children} + {options && ( +
settle(false)} + role="dialog" + aria-modal="true" + > + e.stopPropagation()} + > +
+ {Icon && } +
+ {options.title && ( +

+ {options.title} +

+ )} +

+ {options.message} +

+
+ +
+ +
+ + +
+
+
+ )} +
+ ); +}; diff --git a/frontend/src/components/common/index.ts b/frontend/src/components/common/index.ts index 9692c906..f68369aa 100644 --- a/frontend/src/components/common/index.ts +++ b/frontend/src/components/common/index.ts @@ -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';