fix(ui): kill horizontal overflow in dialogs and cut the worst modal copy (#1732)

* fix(dialogs): kill horizontal overflow in dialogs and cut the worst modal copy

Overflow hardening:
- DialogTitle/DialogDescription and SheetTitle/SheetDescription get
  break-words at the primitive, so long unbroken interpolated strings
  (emails, product names, org numbers) can no longer widen any dialog.
- AccountCombobox's non-flat dropdown is portaled to document.body with
  viewport-clamped geometry (new pure helper account-combobox-position.ts,
  unit-tested), the same fix info-tooltip.tsx applies to TooltipContent:
  the 34rem panel inside a scrollable DialogContent was the root cause of
  sideways-scrolling dialogs. Outside-click checks the portaled node,
  position tracks scroll/resize (capture phase), wheel/touchmove stop at
  the panel so react-remove-scroll's modal lock cannot block its scrolling,
  and DialogContent/SheetContent treat data-dialog-companion nodes as
  inside interactions so clicking the panel never dismisses the dialog.
  The flat variant is unchanged.
- StrikeLinesDialog/CorrectionEntryDialog line rows switch bare 1fr grid
  tracks to minmax(0,1fr) and wrap the sm:contents-promoted AccountCombobox
  in a min-w-0 cell (SendInvoiceDialog's pattern).
- New dialog-overflow-risk ratchet in no-new-antipatterns.mjs: bare fr
  tracks in dialog hosts, whitespace-nowrap inside DialogContent regions
  outside an allowlist, and unportaled >=20rem overlays; baselined at the
  post-fix 7 files.

Copy reduction (convention 7, MatchVoucherDialog precedent):
- New shared RattelseExplainer (HelpPopover) carries the "a posted
  verifikat cannot be edited directly" framing once; CorrectionEntryDialog,
  StrikeLinesDialog, RecordateEntryDialog and CorrectMetadataDialog drop
  their permanent inline explainer boxes and keep at most one sentence
  inline (hardcoded Swedish: verifikat surface).
- SendInvoiceDialog keeps the actual addresses inline and moves the fixed
  CC/BCC framing plus the extra-address rules behind a HelpPopover
  (recipient_additional_hint replaced by recipient_help_fixed and
  recipient_help_additional in both messages files).
- HelpPopover panels gain pointer-events-auto and the companion marker so
  they are actually interactive inside modal dialogs.

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

* fix(bookkeeping): mechanism-accurate rattelse copy and calmer dropdown repositioning

The shared RattelseExplainer claimed every rattelse is logged with who/when
in the verifikat's rattelsehistorik, which is only true for the inline
strike-and-replace track (StrikeLinesDialog, CorrectMetadataDialog). The
storno dialogs (CorrectionEntryDialog, RecordateEntryDialog) never write
that log: their BFL 5 kap 5 trail is the storno chain. The shared component
now keeps only the universally true framing sentence, and each dialog's
popover carries the trail sentence matching its own mechanism.

AccountCombobox's capture-phase scroll/resize handler now skips setState
when the recomputed position is shallow-equal to the current one
(isSameDropdownPosition in the pure position helper, unit-tested) and
ignores scroll events originating inside the portaled panel itself, so
scrolling the account list no longer churns re-renders.

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

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 10:03:52 +02:00
committed by GitHub
co-authored by Claude Fable 5 Jakob Wennberg
parent 68ca152127
commit 834cc4d0e8
17 changed files with 799 additions and 169 deletions
+15 -3
View File
@@ -31,11 +31,23 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
>(({ className, children, onInteractOutside, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
// Companion overlays (AccountCombobox's dropdown, HelpPopover panels)
// 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.
onInteractOutside={(event) => {
onInteractOutside?.(event)
const target = event.target
if (target instanceof Element && target.closest('[data-dialog-companion]')) {
event.preventDefault()
}
}}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid 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
@@ -106,7 +118,7 @@ const DialogTitle = React.forwardRef<
ref={ref}
data-ph-unmask=""
className={cn(
"text-lg leading-none tracking-tight",
"break-words text-lg leading-none tracking-tight",
className
)}
{...props}
@@ -121,7 +133,7 @@ const DialogDescription = React.forwardRef<
<DialogPrimitive.Description
ref={ref}
data-ph-unmask=""
className={cn("text-sm text-muted-foreground", className)}
className={cn("break-words text-sm text-muted-foreground", className)}
{...props}
/>
))
+5 -1
View File
@@ -86,9 +86,13 @@ export function HelpPopover({ children, className }: HelpPopoverProps) {
ref={panelRef}
role="note"
data-help-popover=""
// Inside a modal dialog the panel is DOM-outside DialogContent:
// data-dialog-companion keeps a click in it from dismissing the
// dialog, and pointer-events-auto undoes the modal body lock.
data-dialog-companion=""
// data-ph-unmask: page help is static i18n chrome in session replays.
data-ph-unmask=""
className="fixed z-[60] w-[300px] rounded-lg border border-border bg-popover p-4 text-[13px] leading-relaxed text-foreground shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
className="pointer-events-auto fixed z-[60] w-[300px] rounded-lg border border-border bg-popover p-4 text-[13px] leading-relaxed text-foreground shadow-lg animate-in fade-in slide-in-from-top-1 duration-150"
style={{ top: pos.top, left: pos.left }}
>
{children}
+12 -3
View File
@@ -49,11 +49,20 @@ interface SheetContentProps
const SheetContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
SheetContentProps
>(({ className, children, side = "right", ...props }, ref) => (
>(({ className, children, side = "right", onInteractOutside, ...props }, ref) => (
<SheetPortal>
<SheetOverlay />
<DialogPrimitive.Content
ref={ref}
// Same companion-overlay guard as DialogContent: a portaled panel
// marked data-dialog-companion counts as an inside interaction.
onInteractOutside={(event) => {
onInteractOutside?.(event)
const target = event.target
if (target instanceof Element && target.closest('[data-dialog-companion]')) {
event.preventDefault()
}
}}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background p-6 transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
sideClasses[side],
@@ -105,7 +114,7 @@ const SheetTitle = React.forwardRef<
<DialogPrimitive.Title
ref={ref}
data-ph-unmask=""
className={cn("text-base tracking-tight", className)}
className={cn("break-words text-base tracking-tight", className)}
{...props}
/>
))
@@ -118,7 +127,7 @@ const SheetDescription = React.forwardRef<
<DialogPrimitive.Description
ref={ref}
data-ph-unmask=""
className={cn("text-sm text-muted-foreground", className)}
className={cn("break-words text-sm text-muted-foreground", className)}
{...props}
/>
))