fix(transactions): keep the assistant usable through the whole booking flow (#1975)
* fix(transactions): keep the assistant usable through the whole booking flow Three gaps around the agent sheet during booking on Transaktioner: - The AgentTrigger bubble lived inside #dash-shell, so the inert that non-modal dialogs set on the shell made the assistant impossible to OPEN once a booking dialog was up. Moved outside the shell (it is position: fixed) and raised to z-[45]: above the DialogVeil (z-40) so it stays clickable, below dialog content (z-50). Under true modal dialogs Radix's body pointer-events lock keeps it dead as before. - Wide dialogs centered on the full viewport while the docked sheet (z-60) covered their right edge, hiding e.g. the Granska button. DialogContent now centers in the space left of --agent-dock-w, and the wide booking/review variants cap their width against it. - Step 1 of the flow (template picker, QuickReviewDialog) was still fully modal, so the assistant was dead there. Both are now non-modal with DialogVeil + a shared ref-counted useDashShellInert hook (also replacing the duplicated inert effects in TransactionBookingDialog and NewInvoiceDialog). Their Esc/veil-click dismissal is kept: they hold no half-filled form. Clicks in the agent sheet or its trigger never dismiss any dialog: data-agent-ui counts as inside, same mechanism as data-dialog-companion. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(ui): dialog centering reads a docked-only sheet width variable Skeptic review refuted the first cut: globals.css seeds --agent-dock-w at the 10px frame gutter on :root by design, so the 0px fallback in the new dialog centering never applied and every dialog sat 5px left of center (full-bleed dialogs clipped 5px off-screen). Introduce --agent-sheet-w, set inline by AgentSheetProvider only while the sheet is docked and removed otherwise, so the 0px fallback is real: sheet closed or floating renders byte-identical to the old left-[50%] and old max widths. Also cap the template picker and QuickReview's narrow variant, which could clip off-screen left on narrow desktops with the sheet docked. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
99c94d467a
commit
c981384a0e
@@ -515,7 +515,6 @@ export default async function DashboardLayout({
|
||||
)}
|
||||
</MainContainer>
|
||||
</main>
|
||||
<AgentTrigger hidden={userPrefs?.hide_assistant_fab === true} />
|
||||
{/* One-time expired-trial notice. Sandbox/anonymous demo users have
|
||||
no billing (their companies carry trial grants too), so the gate
|
||||
lives here where both flags are known. Acknowledgement persists
|
||||
@@ -533,6 +532,10 @@ export default async function DashboardLayout({
|
||||
<SettingsHotkey />
|
||||
{settingsModal}
|
||||
</div>
|
||||
{/* Outside #dash-shell on purpose: non-modal dialogs (booking,
|
||||
invoice) set `inert` on the shell while open, and the assistant
|
||||
entry point must stay clickable then, like the sheet itself. */}
|
||||
<AgentTrigger hidden={userPrefs?.hide_assistant_fab === true} />
|
||||
{!isSandbox && (
|
||||
<AnalyticsIdentify
|
||||
user={{
|
||||
|
||||
@@ -8,7 +8,7 @@ import { useSearchParams } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogVeil, useDashShellInert } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
|
||||
@@ -350,6 +350,9 @@ export default function TransactionsPage() {
|
||||
// Template picker dialog
|
||||
const [templatePickerOpen, setTemplatePickerOpen] = useState(false)
|
||||
const [templatePickerTransaction, setTemplatePickerTransaction] = useState<TransactionWithInvoice | null>(null)
|
||||
// The picker renders non-modal (agent sheet stays usable); hand-restore
|
||||
// page modality while it is open. See useDashShellInert in ui/dialog.tsx.
|
||||
useDashShellInert(templatePickerOpen)
|
||||
|
||||
// Invoice picker dialog (manual match)
|
||||
const [invoicePickerOpen, setInvoicePickerOpen] = useState(false)
|
||||
@@ -4020,8 +4023,17 @@ export default function TransactionsPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{templatePickerOpen && <Dialog open onOpenChange={setTemplatePickerOpen}>
|
||||
<DialogContent className="max-w-lg max-h-[80vh] overflow-y-auto">
|
||||
{/* Non-modal so the agent sheet and its trigger stay usable while
|
||||
picking (useDashShellInert above hand-restores page modality).
|
||||
Esc and veil-click still close: the picker holds no user input.
|
||||
Clicks in the assistant don't dismiss: data-agent-ui counts as
|
||||
inside (see DialogContent). */}
|
||||
{templatePickerOpen && <Dialog open onOpenChange={setTemplatePickerOpen} modal={false}>
|
||||
<DialogVeil />
|
||||
{/* Width capped at the space left of a docked sheet (--agent-sheet-w
|
||||
is docked-only) so the picker never clips off-screen left on
|
||||
narrow desktops; sheet closed = the old max-w-lg. */}
|
||||
<DialogContent className="max-w-[min(32rem,calc(100vw-var(--agent-sheet-w,0px)))] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Bokför transaktion</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -563,6 +563,9 @@ export default function AgentSheet({
|
||||
ref={sheetRef}
|
||||
role="dialog"
|
||||
aria-label={displayTitle}
|
||||
// Interactions here must never dismiss an open (non-modal) dialog:
|
||||
// DialogContent treats data-agent-ui as inside the dialog.
|
||||
data-agent-ui=""
|
||||
// z-[60] sits above the mobile bottom nav (z-50) so on phones the sheet
|
||||
// covers the full screen including where the nav would otherwise show.
|
||||
// `hidden` (display:none) when collapsed keeps the component mounted (the
|
||||
|
||||
@@ -274,15 +274,23 @@ export function AgentSheetProvider({
|
||||
// beside the answer. Written as a CSS variable rather than a class on <main>
|
||||
// because the frame layout is a server component; globals.css seeds the
|
||||
// default so the first paint is not a jump.
|
||||
// --agent-sheet-w is the docked-only sibling: unlike --agent-dock-w it has
|
||||
// NO stylesheet default (globals.css seeds dock-w at the 10px frame gutter),
|
||||
// so consumers that must be a no-op when the sheet is closed (dialog
|
||||
// centering and width caps in ui/dialog.tsx and the wide booking/review
|
||||
// dialogs) read this one and get their 0px fallback.
|
||||
useEffect(() => {
|
||||
const root = document.documentElement
|
||||
if (dockWidth === null) {
|
||||
root.style.removeProperty('--agent-dock-w')
|
||||
root.style.removeProperty('--agent-sheet-w')
|
||||
return
|
||||
}
|
||||
root.style.setProperty('--agent-dock-w', `${dockWidth}px`)
|
||||
root.style.setProperty('--agent-sheet-w', `${dockWidth}px`)
|
||||
return () => {
|
||||
root.style.removeProperty('--agent-dock-w')
|
||||
root.style.removeProperty('--agent-sheet-w')
|
||||
}
|
||||
}, [dockWidth])
|
||||
|
||||
|
||||
@@ -222,7 +222,15 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
|
||||
// the page declares a bottom action bar (body[data-page-bottom-bar],
|
||||
// set by e.g. the standalone invoice editor): lift above it so the FAB
|
||||
// never covers the bar's primary button.
|
||||
className={`fixed right-4 z-30 ${visibilityClass} h-12 max-w-[calc(100vw-2rem)] items-stretch rounded-full bg-foreground text-background shadow-lg bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4 md:[body[data-page-bottom-bar]_&]:bottom-20`}
|
||||
// z-[45]: above the DialogVeil (z-40) so the assistant can be OPENED
|
||||
// while a non-modal dialog (booking, invoice) holds the page inert, but
|
||||
// below dialog content (z-50). Under a true modal dialog Radix sets
|
||||
// pointer-events: none on <body>, which keeps the trigger dead there
|
||||
// regardless of z.
|
||||
// data-agent-ui: opening the assistant must not dismiss an open
|
||||
// non-modal dialog (DialogContent treats this as inside).
|
||||
data-agent-ui=""
|
||||
className={`fixed right-4 z-[45] ${visibilityClass} h-12 max-w-[calc(100vw-2rem)] items-stretch rounded-full bg-foreground text-background shadow-lg bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-4 md:[body[data-page-bottom-bar]_&]:bottom-20`}
|
||||
>
|
||||
<button
|
||||
onClick={handleClick}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Dialog, DialogContent, DialogTitle, DialogVeil } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogTitle, DialogVeil, useDashShellInert } from '@/components/ui/dialog'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { createClient } from '@/lib/supabase/client'
|
||||
@@ -112,21 +112,9 @@ export default function NewInvoiceDialog({ open, onOpenChange, copyFromId = null
|
||||
const copyInitial = copyLoad.sourceId === copyFromId ? copyLoad.initial : null
|
||||
const copyLoadFailed = copyLoad.sourceId === copyFromId && copyLoad.failed
|
||||
|
||||
// The dialog is non-modal so the agent sheet (a body-level sibling at
|
||||
// z-[60]) stays clickable and focusable above it: Radix modal mode sets
|
||||
// body pointer-events: none and traps focus, which left the visible chat
|
||||
// input dead. Page modality is restored by hand instead: `inert` on the
|
||||
// dash shell blocks pointer, keyboard, and AT access to the page behind,
|
||||
// while the agent sheet (outside the shell) stays live.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const shell = document.getElementById('dash-shell')
|
||||
if (!shell) return
|
||||
shell.inert = true
|
||||
return () => {
|
||||
shell.inert = false
|
||||
}
|
||||
}, [open])
|
||||
// Non-modal dialog (see below): page modality is restored by hand so the
|
||||
// agent sheet stays live. See useDashShellInert in components/ui/dialog.tsx.
|
||||
useDashShellInert(open)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange} modal={false}>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogVeil, useDashShellInert } from '@/components/ui/dialog'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { ToastAction } from '@/components/ui/toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
@@ -203,6 +203,10 @@ export default function QuickReviewDialog({
|
||||
}
|
||||
}, [open, transaction, t])
|
||||
|
||||
// Non-modal dialog (see the Dialog below): hand-restore page modality so
|
||||
// the agent sheet stays live. See useDashShellInert in ui/dialog.tsx.
|
||||
useDashShellInert(open)
|
||||
|
||||
if (!transaction || !category) return null
|
||||
|
||||
const tx = enrichedTx ?? transaction
|
||||
@@ -432,7 +436,11 @@ export default function QuickReviewDialog({
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={isProcessing ? undefined : (o) => {
|
||||
// Non-modal so the agent sheet and its trigger stay usable during review
|
||||
// (useDashShellInert above hand-restores page modality). Existing
|
||||
// dismissal semantics kept: Esc/veil-click close unless processing, and
|
||||
// assistant clicks never dismiss (data-agent-ui counts as inside).
|
||||
<Dialog modal={false} open={open} onOpenChange={isProcessing ? undefined : (o) => {
|
||||
if (!o) {
|
||||
setUploadedFiles([])
|
||||
setPickedInboxDocs([])
|
||||
@@ -440,7 +448,11 @@ export default function QuickReviewDialog({
|
||||
}
|
||||
onOpenChange(o)
|
||||
}}>
|
||||
<DialogContent className={preAttachedDocumentId ? 'max-w-6xl max-h-[90vh] overflow-y-auto' : 'max-w-md sm:max-w-lg max-h-[85vh] overflow-y-auto'}>
|
||||
<DialogVeil />
|
||||
{/* Both variants cap at the space left of a docked agent sheet so the
|
||||
right edge never lands unreachable under it (sheet is z-60).
|
||||
--agent-sheet-w is docked-only: sheet closed = the old widths. */}
|
||||
<DialogContent className={preAttachedDocumentId ? 'max-w-[min(72rem,calc(100vw-var(--agent-sheet-w,0px)))] max-h-[90vh] overflow-y-auto' : 'max-w-[min(28rem,calc(100vw-var(--agent-sheet-w,0px)))] sm:max-w-[min(32rem,calc(100vw-var(--agent-sheet-w,0px)))] max-h-[85vh] overflow-y-auto'}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogVeil } from '@/components/ui/dialog'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogVeil, useDashShellInert } from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import { formatCurrency, formatDate } from '@/lib/utils'
|
||||
@@ -158,20 +158,9 @@ 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])
|
||||
// Non-modal dialog (see below): page modality is restored by hand so the
|
||||
// agent sheet stays live. See useDashShellInert in components/ui/dialog.tsx.
|
||||
useDashShellInert(open)
|
||||
|
||||
if (!transaction) return null
|
||||
|
||||
@@ -271,7 +260,11 @@ export default function TransactionBookingDialog({
|
||||
}} modal={false}>
|
||||
<DialogVeil />
|
||||
<DialogContent
|
||||
className="max-w-6xl max-h-[90vh] overflow-y-auto"
|
||||
// Width caps at the space left of a docked agent sheet so the form's
|
||||
// right edge, and the Granska button, never end up unreachable under
|
||||
// the sheet (z-60 over z-50). --agent-sheet-w is docked-only, so with
|
||||
// the sheet closed this is exactly the old max-w-6xl.
|
||||
className="max-w-[min(72rem,calc(100vw-var(--agent-sheet-w,0px)))] 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
|
||||
|
||||
@@ -40,11 +40,13 @@ const DialogContent = React.forwardRef<
|
||||
// are portaled to document.body so this content's overflow-y-auto can
|
||||
// never clip them. DOM-wise that puts them OUTSIDE the dialog, so Radix
|
||||
// would otherwise dismiss the dialog on a pointerdown inside them:
|
||||
// anything marked data-dialog-companion counts as inside.
|
||||
// anything marked data-dialog-companion counts as inside. The agent
|
||||
// sheet and its trigger (data-agent-ui) count as inside for the same
|
||||
// reason: writing to the assistant must never dismiss a dialog.
|
||||
onInteractOutside={(event) => {
|
||||
onInteractOutside?.(event)
|
||||
const target = event.target
|
||||
if (target instanceof Element && target.closest('[data-dialog-companion]')) {
|
||||
if (target instanceof Element && target.closest('[data-dialog-companion], [data-agent-ui]')) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}}
|
||||
@@ -53,7 +55,14 @@ const DialogContent = React.forwardRef<
|
||||
// child's min-content, and nowrap text (truncate) counts at full width
|
||||
// there, so one long description widens every sibling past the dialog
|
||||
// edge. minmax(0,1fr) caps the track at the content box.
|
||||
"fixed left-[50%] top-[50%] z-50 grid grid-cols-[minmax(0,1fr)] w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-98 data-[state=open]:zoom-in-98 sm:rounded-xl",
|
||||
// left: centered in the viewport MINUS the docked agent sheet.
|
||||
// --agent-sheet-w exists as an inline var on <html> ONLY while the
|
||||
// sheet is docked open (AgentSheetProvider); closed/floating falls to
|
||||
// the 0px fallback, making this exactly left-[50%]. Do NOT read
|
||||
// --agent-dock-w here: globals.css seeds that at 10px permanently.
|
||||
// Without the shift a wide dialog centers under the sheet and its
|
||||
// right edge becomes unreachable (sheet is z-60).
|
||||
"fixed left-[calc((100vw-var(--agent-sheet-w,0px))/2)] top-[50%] z-50 grid grid-cols-[minmax(0,1fr)] w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-[var(--shadow-md)] max-h-[calc(100dvh-2rem)] overflow-y-auto scrollbar-visible data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-98 data-[state=open]:zoom-in-98 sm:rounded-xl",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -84,6 +93,32 @@ const DialogVeil = () => (
|
||||
</DialogPortal>
|
||||
)
|
||||
|
||||
// Ref-counted so overlapping non-modal dialogs (e.g. the template picker
|
||||
// closing in the same commit as the booking dialog opens) cannot fight over
|
||||
// the flag: the shell stays inert until the LAST open dialog releases it.
|
||||
let dashShellInertCount = 0
|
||||
|
||||
/**
|
||||
* Hand-rolled page modality for `modal={false}` dialogs. Radix non-modal mode
|
||||
* drops the focus trap, aria-hiding, and body pointer-events lock, so the
|
||||
* page behind the DialogVeil would stay keyboard/AT/tap-reachable. `inert` on
|
||||
* the dash shell blocks all of that while the agent sheet and its trigger
|
||||
* (both outside the shell) stay live. Pair with DialogVeil.
|
||||
*/
|
||||
const useDashShellInert = (open: boolean) => {
|
||||
React.useEffect(() => {
|
||||
if (!open) return
|
||||
const shell = document.getElementById('dash-shell')
|
||||
if (!shell) return
|
||||
dashShellInertCount++
|
||||
shell.inert = true
|
||||
return () => {
|
||||
dashShellInertCount--
|
||||
if (dashShellInertCount <= 0) shell.inert = false
|
||||
}
|
||||
}, [open])
|
||||
}
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
@@ -151,6 +186,7 @@ export {
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogVeil,
|
||||
useDashShellInert,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
|
||||
Reference in New Issue
Block a user