mirror of https://github.com/usememos/memos
feat(web): introduce accessible ConfirmDialog and migrate confirmations; add Markdown-safe descriptions
Why - window.confirm is not supported on Brave Mobile for iOS, which blocked destructive actions like deleting memos. Replacing it with an accessible, app-native dialog restores mobile functionality and improves UX. What changed - New ConfirmDialog component - Replaces window.confirm usage across the app. - Props: open/onOpenChange, title, description or descriptionMarkdown, confirm/cancel labels, onConfirm, confirmVariant. - Prevents double-submit and accidental dismiss while confirming (loading state). - Markdown support for dialog descriptions - descriptionMarkdown renders via marked and is sanitized with DOMPurify before injection. - Keeps translations readable (Markdown) and safe (sanitized HTML). - Member management flows - Updated archive/delete dialogs to use ConfirmDialog. - Added toast notifications for archive, restore, and delete actions. - i18n: added/updated relevant translation keys (en). Accessibility and mobile - Dialog buttons are touch-friendly. - Escape and outside-click behavior matches expectations. Manual Tests - Verified in Brave desktop (v1.82.166) and Brave for iOS (v1.81 (134)) - Memos: - Archive → confirm archival and shows success toast. - Restore (only when archived) → confirm restoration and shows success toast. - Delete → destructive dialog → confirm deletion and shows success toast. - Shortcuts: create → menu → Delete → dialog appears; cancel keeps; confirm deletes and list refreshes. - Access tokens: Settings → Access Tokens → Delete → dialog title shows masked token; confirm deletes. - Members: Settings → Members → non-current user: - Archive → warning dialog → confirm archives. - Delete (only when archived) → destructive dialog → confirm deletes. - Sessions: Settings → Sessions → Revoke non-current session → dialog appears; confirm revokes; current session remains disabled. - Webhooks: Settings → Webhooks → Delete → dialog appears; confirm deletes and list refreshes. - Mobile/accessibility: focus trap, inert background, tappable buttons, Escape/outside-click behavior verified. Notes / follow-ups - Deleting a member currently removes the account but does not cascade-delete the member’s content. Not sure if this is intended or not, so I left the warning description more general for now.pull/5111/head
parent
7ab57f8ed2
commit
30795d1d9c
@ -0,0 +1,72 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
import { marked } from "marked";
|
||||||
|
|
||||||
|
export interface ConfirmDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
title: React.ReactNode;
|
||||||
|
description?: React.ReactNode;
|
||||||
|
descriptionMarkdown?: string;
|
||||||
|
confirmLabel: string;
|
||||||
|
cancelLabel: string;
|
||||||
|
onConfirm: () => void | Promise<void>;
|
||||||
|
confirmVariant?: "default" | "destructive";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
descriptionMarkdown,
|
||||||
|
confirmLabel,
|
||||||
|
cancelLabel,
|
||||||
|
onConfirm,
|
||||||
|
confirmVariant = "default",
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
const [loading, setLoading] = React.useState(false);
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
await onConfirm();
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// prepare sanitized HTML if Markdown was provided
|
||||||
|
const descriptionHtml =
|
||||||
|
typeof descriptionMarkdown === "string"
|
||||||
|
? DOMPurify.sanitize(String(marked.parse(descriptionMarkdown)))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={(o: boolean) => !loading && onOpenChange(o)}>
|
||||||
|
<DialogContent size="sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
{descriptionHtml ? (
|
||||||
|
<DialogDescription dangerouslySetInnerHTML={{ __html: descriptionHtml }} />
|
||||||
|
) : description ? (
|
||||||
|
<DialogDescription>{description}</DialogDescription>
|
||||||
|
) : null}
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" disabled={loading} onClick={() => onOpenChange(false)}>
|
||||||
|
{cancelLabel}
|
||||||
|
</Button>
|
||||||
|
<Button variant={confirmVariant} disabled={loading} onClick={handleConfirm}>
|
||||||
|
{confirmLabel}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
Loading…
Reference in New Issue