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:
Mattsson
2026-08-27 14:22:20 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent 99c94d467a
commit c981384a0e
9 changed files with 107 additions and 44 deletions
+39 -3
View File
@@ -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,