From 1db94349303e8855f631ae2d377ea12a5ad79733 Mon Sep 17 00:00:00 2001 From: Moheeb Zara Date: Fri, 15 May 2026 22:18:15 -0700 Subject: [PATCH] feat(ux): accessible confirm dialog replaces window.confirm - new components/ui/confirm.tsx: ConfirmProvider + useConfirm() built on the existing Radix alert-dialog (focus trap, ESC, aria for free) - wired once into the app layout - all 13 native window.confirm() delete/remove guards now use the styled, accessible confirm with a destructive action button --- app/(app)/contacts/contacts-client.tsx | 4 +- .../customize/panels/area-leads-panel.tsx | 4 +- app/(app)/customize/panels/areas-panel.tsx | 4 +- app/(app)/customize/panels/invites-panel.tsx | 4 +- .../customize/panels/onboarding-panel.tsx | 4 +- app/(app)/customize/panels/roles-panel.tsx | 4 +- app/(app)/customize/panels/tiers-panel.tsx | 4 +- app/(app)/forum/[id]/thread-actions.tsx | 4 +- app/(app)/layout.tsx | 3 + app/(app)/members/members-client.tsx | 4 +- app/(app)/ops/ops-client.tsx | 7 +- app/(app)/projects/projects-client.tsx | 4 +- components/comments/comment-thread.tsx | 4 +- components/ui/confirm.tsx | 91 +++++++++++++++++++ 14 files changed, 132 insertions(+), 13 deletions(-) create mode 100644 components/ui/confirm.tsx diff --git a/app/(app)/contacts/contacts-client.tsx b/app/(app)/contacts/contacts-client.tsx index 64b89dd..5365f80 100644 --- a/app/(app)/contacts/contacts-client.tsx +++ b/app/(app)/contacts/contacts-client.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { Plus, X, Pencil, Contact as ContactIcon } from 'lucide-react' import { createContact, updateContact, deleteContact } from '@/lib/actions' +import { useConfirm } from '@/components/ui/confirm' import type { Tables } from '@/types/database' import { PageTitle } from '@/components/ui/page-title' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty' @@ -24,6 +25,7 @@ const GROUPS: Record = { } export function ContactsClient({ contacts: initialContacts }: { contacts: Contact[] }) { + const confirm = useConfirm() const [contacts, setContacts] = useState(initialContacts) const [search, setSearch] = useState('') const [showCreate, setShowCreate] = useState(false) @@ -84,7 +86,7 @@ export function ContactsClient({ contacts: initialContacts }: { contacts: Contac } async function handleDelete(id: string) { - if (!confirm('Delete this contact?')) return + if (!(await confirm({ title: 'Delete contact', description: 'This contact will be permanently removed.', confirmText: 'Delete', destructive: true }))) return const result = await deleteContact(id) if (!result.error) setContacts(prev => prev.filter(c => c.id !== id)) } diff --git a/app/(app)/customize/panels/area-leads-panel.tsx b/app/(app)/customize/panels/area-leads-panel.tsx index 08115e1..d6e805c 100644 --- a/app/(app)/customize/panels/area-leads-panel.tsx +++ b/app/(app)/customize/panels/area-leads-panel.tsx @@ -6,6 +6,7 @@ import { toast } from 'sonner' import { UserCog } from 'lucide-react' import { createAreaLeadRole, assignAreaLead, unassignAreaLead, deleteAreaLeadRole } from '@/lib/actions' import { Card } from './card' +import { useConfirm } from '@/components/ui/confirm' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' interface AreaLead { @@ -26,6 +27,7 @@ interface Props { export function AreaLeadsPanel({ isAdmin, areaLeads: initial, members }: Props) { const router = useRouter() + const confirm = useConfirm() const [rows, setRows] = useState(initial) const [showNew, setShowNew] = useState(false) const [d, setD] = useState({ area_code: '', name: '' }) @@ -116,7 +118,7 @@ export function AreaLeadsPanel({ isAdmin, areaLeads: initial, members }: Props) - + )} diff --git a/app/(app)/customize/panels/invites-panel.tsx b/app/(app)/customize/panels/invites-panel.tsx index c00398b..f46ee61 100644 --- a/app/(app)/customize/panels/invites-panel.tsx +++ b/app/(app)/customize/panels/invites-panel.tsx @@ -5,6 +5,7 @@ import { toast } from 'sonner' import { Ticket } from 'lucide-react' import { createInvite, updateInvite, deleteInvite } from '@/lib/actions' import { Card } from './card' +import { useConfirm } from '@/components/ui/confirm' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' import type { Invite } from './types' @@ -14,6 +15,7 @@ function inviteLink(code: string) { } export function InvitesPanel({ isAdmin, invites: initial }: { isAdmin: boolean; invites: Invite[] }) { + const confirm = useConfirm() const [invites, setInvites] = useState(initial) const [showNew, setShowNew] = useState(false) const [d, setD] = useState({ code: '', label: '', expires_at: '', max_uses: '' }) @@ -75,7 +77,7 @@ export function InvitesPanel({ isAdmin, invites: initial }: { isAdmin: boolean; {isAdmin && ( <> - + )} diff --git a/app/(app)/customize/panels/onboarding-panel.tsx b/app/(app)/customize/panels/onboarding-panel.tsx index 5c9c568..1f62a7b 100644 --- a/app/(app)/customize/panels/onboarding-panel.tsx +++ b/app/(app)/customize/panels/onboarding-panel.tsx @@ -5,10 +5,12 @@ import { toast } from 'sonner' import { ListChecks } from 'lucide-react' import { createOnboardingStep, updateOnboardingStep, deleteOnboardingStep } from '@/lib/actions' import { Card } from './card' +import { useConfirm } from '@/components/ui/confirm' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' import type { Step } from './types' export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean; steps: Step[] }) { + const confirm = useConfirm() const [steps, setSteps] = useState(initial) return ( @@ -65,7 +67,7 @@ export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean; Required {!s.is_system && ( - + )} )} diff --git a/app/(app)/customize/panels/roles-panel.tsx b/app/(app)/customize/panels/roles-panel.tsx index 35bf3ce..c233fbf 100644 --- a/app/(app)/customize/panels/roles-panel.tsx +++ b/app/(app)/customize/panels/roles-panel.tsx @@ -7,11 +7,13 @@ import { upsertRoleLabel, createCustomRole, updateCustomRole, deleteCustomRole } import { BUILTIN_ROLES, DEFAULT_ROLE_LABELS } from '@/lib/role-labels' import { Tags } from 'lucide-react' import { Card } from './card' +import { useConfirm } from '@/components/ui/confirm' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from '@/components/ui/empty' import type { RoleLabelRow, CustomRole } from './types' export function RolesPanel({ isAdmin, roleLabels, customRoles: initial }: { isAdmin: boolean; roleLabels: RoleLabelRow[]; customRoles: CustomRole[] }) { const router = useRouter() + const confirm = useConfirm() const [customRoles, setCustomRoles] = useState(initial) const [showNew, setShowNew] = useState(false) const [draft, setDraft] = useState({ slug: '', name: '', color: '#d4ff00', description: '' }) @@ -142,7 +144,7 @@ export function RolesPanel({ isAdmin, roleLabels, customRoles: initial }: { isAd /> {isAdmin && ( ) : ( - + )} )} diff --git a/app/(app)/forum/[id]/thread-actions.tsx b/app/(app)/forum/[id]/thread-actions.tsx index 2dc0f7b..5069f0f 100644 --- a/app/(app)/forum/[id]/thread-actions.tsx +++ b/app/(app)/forum/[id]/thread-actions.tsx @@ -4,6 +4,7 @@ import { useRouter } from 'next/navigation' import { toast } from 'sonner' import { Pin, Lock, Trash2 } from 'lucide-react' import { updateForumThread, deleteForumThread } from '@/lib/actions' +import { useConfirm } from '@/components/ui/confirm' interface Props { threadId: string @@ -15,6 +16,7 @@ interface Props { export function ThreadActions({ threadId, pinned, locked, canModerate, isAuthor }: Props) { const router = useRouter() + const confirm = useConfirm() async function togglePinned() { const result = await updateForumThread(threadId, { pinned: !pinned }) @@ -29,7 +31,7 @@ export function ThreadActions({ threadId, pinned, locked, canModerate, isAuthor } async function handleDelete() { - if (!confirm('Delete this thread? All comments are deleted too.')) return + if (!(await confirm({ title: 'Delete thread', description: 'All comments are deleted too. This cannot be undone.', confirmText: 'Delete', destructive: true }))) return const result = await deleteForumThread(threadId) if (result.error) { toast.error(result.error); return } toast.success('Thread deleted') diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx index d7456e7..302caaa 100644 --- a/app/(app)/layout.tsx +++ b/app/(app)/layout.tsx @@ -1,6 +1,7 @@ import { createClient } from '@/lib/supabase/server' import { redirect } from 'next/navigation' import { AppSidebar } from '@/components/app-sidebar' +import { ConfirmProvider } from '@/components/ui/confirm' import { getRoleLabelMap } from '@/lib/role-labels' // This layout is the auth/onboarding gate for the whole app. It must never be @@ -56,6 +57,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod const roleName = roleMap[member.role]?.name ?? member.role return ( + + ) } diff --git a/app/(app)/members/members-client.tsx b/app/(app)/members/members-client.tsx index a1030f8..cc9d756 100644 --- a/app/(app)/members/members-client.tsx +++ b/app/(app)/members/members-client.tsx @@ -8,6 +8,7 @@ import { toast } from 'sonner' import { useRouter } from 'next/navigation' import type { Tables } from '@/types/database' import { PageTitle } from '@/components/ui/page-title' +import { useConfirm } from '@/components/ui/confirm' type Member = Tables<'space_members'> type AreaLeadRole = { id: string; area_name: string; lead_id: string | null } @@ -29,6 +30,7 @@ const isAdmin = (role: string) => role === 'admin' || role === 'board' export function MembersClient({ members: initialMembers, currentRole, areaLeadRoles = [] }: Props) { const router = useRouter() + const confirm = useConfirm() const [members, setMembers] = useState(initialMembers) async function makeAreaLead(memberId: string, areaLeadRoleId: string) { @@ -105,7 +107,7 @@ export function MembersClient({ members: initialMembers, currentRole, areaLeadRo } async function handleRemove(memberId: string) { - if (!confirm('Remove this member from the space?')) return + if (!(await confirm({ title: 'Remove member', description: 'This member will be removed from the space.', confirmText: 'Remove', destructive: true }))) return const result = await removeMember(memberId) if (!result.error) { setMembers(prev => prev.filter(m => m.id !== memberId)) diff --git a/app/(app)/ops/ops-client.tsx b/app/(app)/ops/ops-client.tsx index 05bb4a1..df84875 100644 --- a/app/(app)/ops/ops-client.tsx +++ b/app/(app)/ops/ops-client.tsx @@ -12,6 +12,7 @@ import { createClient } from '@/lib/supabase/client' import { toast } from 'sonner' import type { Tables, TablesInsert } from '@/types/database' import { PageTitle } from '@/components/ui/page-title' +import { useConfirm } from '@/components/ui/confirm' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyContent } from '@/components/ui/empty' type KbEntry = Tables<'knowledge_base'> @@ -355,6 +356,7 @@ function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial aclRoleOptions?: { value: string; label: string }[] aclInitial?: string[] }) { + const confirm = useConfirm() const [revealed, setRevealed] = useState(false) const [value, setValue] = useState(null) const [loading, setLoading] = useState(false) @@ -371,7 +373,7 @@ function SecretRow({ secret, onDelete, canManageAcl, aclRoleOptions, aclInitial } async function handleDelete() { - if (!confirm(`Delete secret "${secret.title}"? This cannot be undone.`)) return + if (!(await confirm({ title: 'Delete secret', description: `"${secret.title}" cannot be undone.`, confirmText: 'Delete', destructive: true }))) return const result = await deleteSecret(secret.id) if (result.error) { toast.error(result.error); return } toast.success('Secret deleted') @@ -443,13 +445,14 @@ function KbEntryRow({ aclRoleOptions?: { value: string; label: string }[] aclByEntity?: Record }) { + const confirm = useConfirm() const [viewing, setViewing] = useState(false) const aclEntityType: 'kb' | 'process' = entry.tags?.includes('process') ? 'process' : 'kb' const aclInitial = aclByEntity?.[`${aclEntityType}:${entry.id}`] ?? [] async function handleDelete(e: React.MouseEvent) { e.stopPropagation() - if (!confirm(`Delete "${entry.title}"?`)) return + if (!(await confirm({ title: 'Delete entry', description: `"${entry.title}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return const result = await deleteKbEntry(entry.id) if ('error' in result && result.error) { toast.error(result.error); return } toast.success('Entry deleted') diff --git a/app/(app)/projects/projects-client.tsx b/app/(app)/projects/projects-client.tsx index 5a1225b..e97af6f 100644 --- a/app/(app)/projects/projects-client.tsx +++ b/app/(app)/projects/projects-client.tsx @@ -3,6 +3,7 @@ import { useState } from 'react' import { Plus, X } from 'lucide-react' import { createProject, updateProjectStatus, deleteProject } from '@/lib/actions' +import { useConfirm } from '@/components/ui/confirm' import type { Tables } from '@/types/database' import { PageTitle } from '@/components/ui/page-title' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription, EmptyContent } from '@/components/ui/empty' @@ -23,6 +24,7 @@ export function ProjectsClient({ spaceId, areas, }: { projects: Project[]; spaceId: string; areas?: string[] }) { + const confirm = useConfirm() const AREAS = areas && areas.length > 0 ? areas : DEFAULT_AREAS const [projects, setProjects] = useState(initialProjects) const [showCreate, setShowCreate] = useState(false) @@ -59,7 +61,7 @@ export function ProjectsClient({ } async function handleDelete(projectId: string) { - if (!confirm('Delete this project?')) return + if (!(await confirm({ title: 'Delete project', description: 'This project will be permanently removed.', confirmText: 'Delete', destructive: true }))) return const result = await deleteProject(projectId) if (!result.error) setProjects(prev => prev.filter(p => p.id !== projectId)) } diff --git a/components/comments/comment-thread.tsx b/components/comments/comment-thread.tsx index 8640fc1..b815bd5 100644 --- a/components/comments/comment-thread.tsx +++ b/components/comments/comment-thread.tsx @@ -8,6 +8,7 @@ import remarkGfm from 'remark-gfm' import { addComment, deleteComment } from '@/lib/actions' import { Trash2, MessageCircle } from 'lucide-react' import { Empty, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty' +import { useConfirm } from '@/components/ui/confirm' export type CommentEntityType = 'forum_thread' | 'proposal' | 'incident' | 'policy' @@ -31,6 +32,7 @@ interface Props { export function CommentThread({ entityType, entityId, comments, currentMemberId, canModerate, locked }: Props) { const router = useRouter() + const confirm = useConfirm() const [body, setBody] = useState('') const [saving, setSaving] = useState(false) @@ -46,7 +48,7 @@ export function CommentThread({ entityType, entityId, comments, currentMemberId, } async function handleDelete(commentId: string) { - if (!confirm('Delete this comment?')) return + if (!(await confirm({ title: 'Delete comment', description: 'This comment will be permanently removed.', confirmText: 'Delete', destructive: true }))) return const result = await deleteComment(commentId) if (result.error) { toast.error(result.error); return } toast.success('Comment deleted') diff --git a/components/ui/confirm.tsx b/components/ui/confirm.tsx new file mode 100644 index 0000000..7c496d2 --- /dev/null +++ b/components/ui/confirm.tsx @@ -0,0 +1,91 @@ +'use client' + +import * as React from 'react' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { buttonVariants } from '@/components/ui/button' + +// An accessible replacement for window.confirm(). Wrap the app once in +// and call `const confirm = useConfirm()` then +// `if (!(await confirm({ title, ... }))) return` at the call site, so the +// imperative shape stays close to the native confirm() it replaces. + +type ConfirmOptions = { + title: string + description?: string + confirmText?: string + cancelText?: string + destructive?: boolean +} + +type ConfirmFn = (opts: ConfirmOptions) => Promise + +const ConfirmContext = React.createContext(null) + +export function useConfirm(): ConfirmFn { + const ctx = React.useContext(ConfirmContext) + if (!ctx) throw new Error('useConfirm must be used within ConfirmProvider') + return ctx +} + +export function ConfirmProvider({ children }: { children: React.ReactNode }) { + const [opts, setOpts] = React.useState(null) + const resolverRef = React.useRef<((v: boolean) => void) | null>(null) + + const confirm = React.useCallback((next) => { + setOpts(next) + return new Promise((resolve) => { + resolverRef.current = resolve + }) + }, []) + + const settle = React.useCallback((value: boolean) => { + resolverRef.current?.(value) + resolverRef.current = null + setOpts(null) + }, []) + + return ( + + {children} + { + if (!open) settle(false) + }} + > + + + {opts?.title} + {opts?.description ? ( + {opts.description} + ) : null} + + + settle(false)}> + {opts?.cancelText ?? 'Cancel'} + + settle(true)} + className={ + opts?.destructive + ? buttonVariants({ variant: 'destructive' }) + : undefined + } + > + {opts?.confirmText ?? 'Confirm'} + + + + + + ) +}