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
This commit is contained in:
Moheeb Zara
2026-05-15 22:18:15 -07:00
parent 64d7d626db
commit 1db9434930
14 changed files with 132 additions and 13 deletions
@@ -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<AreaLead[]>(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)
</select>
<button
onClick={async () => {
if (!confirm(`Delete area-lead role "${r.area_name}"?`)) return
if (!(await confirm({ title: 'Delete area-lead role', description: `"${r.area_name}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return
const res = await deleteAreaLeadRole(r.id)
if (res.error) { toast.error(res.error); return }
setRows(prev => prev.filter(x => x.id !== r.id))
+3 -1
View File
@@ -5,10 +5,12 @@ import { toast } from 'sonner'
import { LayoutGrid } from 'lucide-react'
import { createArea, updateArea, deleteArea } 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 { Area } from './types'
export function AreasPanel({ isAdmin, areas: initial }: { isAdmin: boolean; areas: Area[] }) {
const confirm = useConfirm()
const [areas, setAreas] = useState<Area[]>(initial)
const [showNew, setShowNew] = useState(false)
const [d, setD] = useState({ code: '', name: '', icon: '' })
@@ -57,7 +59,7 @@ export function AreasPanel({ isAdmin, areas: initial }: { isAdmin: boolean; area
{isAdmin && (
<div className="flex items-center gap-1.5">
<button onClick={async () => { const res = await updateArea({ areaId: a.id, is_archived: !a.is_archived }); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.map(x => x.id === a.id ? { ...x, is_archived: !x.is_archived } : x)) }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition">{a.is_archived ? 'Restore' : 'Archive'}</button>
<button onClick={async () => { if (!confirm(`Delete area "${a.name}"?`)) return; const res = await deleteArea(a.id); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.filter(x => x.id !== a.id)); toast.success('Deleted') }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
<button onClick={async () => { if (!(await confirm({ title: 'Delete area', description: `"${a.name}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return; const res = await deleteArea(a.id); if (res.error) { toast.error(res.error); return } setAreas(prev => prev.filter(x => x.id !== a.id)); toast.success('Deleted') }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
</div>
)}
</li>
+3 -1
View File
@@ -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<Invite[]>(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 && (
<>
<button onClick={async () => { const res = await updateInvite(inv.id, { is_enabled: !inv.is_enabled }); if (res.error) { toast.error(res.error); return } setInvites(prev => prev.map(x => x.id === inv.id ? { ...x, is_enabled: !x.is_enabled } : x)) }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition">{inv.is_enabled ? 'Disable' : 'Enable'}</button>
<button onClick={async () => { if (!confirm(`Delete invite "${inv.code}"?`)) return; const res = await deleteInvite(inv.id); if (res.error) { toast.error(res.error); return } setInvites(prev => prev.filter(x => x.id !== inv.id)); toast.success('Deleted') }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
<button onClick={async () => { if (!(await confirm({ title: 'Delete invite', description: `"${inv.code}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return; const res = await deleteInvite(inv.id); if (res.error) { toast.error(res.error); return } setInvites(prev => prev.filter(x => x.id !== inv.id)); toast.success('Deleted') }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
</>
)}
</div>
@@ -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<Step[]>(initial)
return (
@@ -65,7 +67,7 @@ export function OnboardingPanel({ isAdmin, steps: initial }: { isAdmin: boolean;
<span className="font-mono text-[10px] text-muted-foreground uppercase tracking-widest">Required</span>
</label>
{!s.is_system && (
<button onClick={async () => { if (!confirm(`Delete step "${s.title}"?`)) return; const res = await deleteOnboardingStep(s.id); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.filter(x => x.id !== s.id)); toast.success('Deleted') }} className="ml-auto font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
<button onClick={async () => { if (!(await confirm({ title: 'Delete step', description: `"${s.title}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return; const res = await deleteOnboardingStep(s.id); if (res.error) { toast.error(res.error); return } setSteps(prev => prev.filter(x => x.id !== s.id)); toast.success('Deleted') }} className="ml-auto font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
)}
</div>
)}
+3 -1
View File
@@ -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<CustomRole[]>(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 && (
<button
onClick={async () => { if (!confirm(`Delete role "${r.name}"?`)) return; const res = await deleteCustomRole(r.id); if (res.error) { toast.error(res.error); return } setCustomRoles(prev => prev.filter(x => x.id !== r.id)); toast.success('Deleted') }}
onClick={async () => { if (!(await confirm({ title: 'Delete role', description: `"${r.name}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return; const res = await deleteCustomRole(r.id); if (res.error) { toast.error(res.error); return } setCustomRoles(prev => prev.filter(x => x.id !== r.id)); toast.success('Deleted') }}
className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition"
>
Delete
+3 -1
View File
@@ -5,10 +5,12 @@ import { toast } from 'sonner'
import { CreditCard } from 'lucide-react'
import { createTier, updateTier, deleteTier } 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 { Tier } from './types'
export function TiersPanel({ isAdmin, tiers: initial }: { isAdmin: boolean; tiers: Tier[] }) {
const confirm = useConfirm()
const [tiers, setTiers] = useState<Tier[]>(initial)
const [showNew, setShowNew] = useState(false)
const [d, setD] = useState({ slug: '', name: '', dollars: '0', cadence: 'monthly' as Tier['billing_cadence'], description: '' })
@@ -75,7 +77,7 @@ export function TiersPanel({ isAdmin, tiers: initial }: { isAdmin: boolean; tier
{t.is_system ? (
<button onClick={async () => { const res = await updateTier(t.id, { is_archived: !t.is_archived }); if (res.error) { toast.error(res.error); return } setTiers(prev => prev.map(x => x.id === t.id ? { ...x, is_archived: !x.is_archived } : x)) }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-primary hover:text-primary transition">{t.is_archived ? 'Restore' : 'Archive'}</button>
) : (
<button onClick={async () => { if (!confirm(`Delete tier "${t.name}"?`)) return; const res = await deleteTier(t.id); if (res.error) { toast.error(res.error); return } setTiers(prev => prev.filter(x => x.id !== t.id)); toast.success('Tier deleted') }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
<button onClick={async () => { if (!(await confirm({ title: 'Delete tier', description: `"${t.name}" will be permanently removed.`, confirmText: 'Delete', destructive: true }))) return; const res = await deleteTier(t.id); if (res.error) { toast.error(res.error); return } setTiers(prev => prev.filter(x => x.id !== t.id)); toast.success('Tier deleted') }} className="font-mono text-[10px] border border-border px-2 py-1 rounded hover:border-red-500 hover:text-red-500 transition">Delete</button>
)}
</div>
)}