fix(transactions): keep booking dialog open while typing in the agent sheet (#1966)

* fix(transactions): keep booking dialog open while typing in the agent sheet

TransactionBookingDialog was a default modal Radix dialog; the agent
sheet is a fixed z-[60] panel portaled outside its DOM, so a click in
the assistant's text field counted as an outside interaction and
dismissed the dialog, losing the half-filled booking.

Apply the established non-modal convention (NewInvoiceDialog /
NewJournalEntryDialog): modal={false} + DialogVeil, with escape and
outside-dismiss prevented. Closing is explicit via the header X.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(transactions): restore page modality via inert while booking dialog is open

Skeptic review caught that the non-modal convention was ported
incompletely: NewInvoiceDialog pairs modal={false} with an inert effect
on #dash-shell. Without it the background page stayed keyboard-, AT-,
and (mobile nav) tap-reachable behind the veil, so a stray Shift+Tab
plus Enter could navigate away and destroy a half-filled booking.

Also corrects the comment that cited NewJournalEntryDialog as non-modal
precedent (it is plain modal).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-08-26 23:21:29 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent ff88e3de05
commit 98aff7c577
@@ -1,8 +1,8 @@
'use client'
import { useMemo, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogVeil } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
@@ -158,6 +158,21 @@ export default function TransactionBookingDialog({
return { bankAccount: account, bankAccountName: matched?.name ?? null }
}, [transaction, cashAccounts, cashAccountsLoading])
// The dialog is non-modal (see the Dialog below), so Radix does not trap
// focus or inert the page. Restore page modality by hand: `inert` on the
// dash shell blocks pointer, keyboard, and AT access to the page behind
// (including the mobile bottom nav, which sits above the veil), while the
// agent sheet (outside the shell) stays live. Same as NewInvoiceDialog.
useEffect(() => {
if (!open) return
const shell = document.getElementById('dash-shell')
if (!shell) return
shell.inert = true
return () => {
shell.inert = false
}
}, [open])
if (!transaction) return null
const isIncome = transaction.amount > 0
@@ -253,8 +268,21 @@ export default function TransactionBookingDialog({
setInboxPickerOpen(false)
}
onOpenChange(o)
}}>
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
}} modal={false}>
<DialogVeil />
<DialogContent
className="max-w-6xl max-h-[90vh] overflow-y-auto"
// Non-modal so the agent sheet (fixed z-[60], portaled outside this
// dialog) stays interactive beside a booking in progress; a click in
// its text field must not count as outside-dismissal. A half-booked
// transaction must also survive a stray Escape or backdrop click.
// Closing is explicit: the header X. Same convention as
// NewInvoiceDialog (which pairs non-modality with the inert effect
// above).
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader>
<DialogTitle>{t('title')}</DialogTitle>
<DialogDescription className="sr-only">