Files
accounted/components/ui/dialog.tsx
T
834cc4d0e8 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>
2026-08-20 10:03:52 +02:00

155 lines
5.0 KiB
TypeScript

"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/50 dark:bg-black/60 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ 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
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
/**
* Backdrop for non-modal dialogs. Radix renders no Overlay when the root has
* `modal={false}`, so dialogs that opt out of modality (to keep the agent
* sheet interactive above them) render this alongside DialogContent to keep
* the standard veil. z-40 sits under DialogContent (z-50) and the agent
* sheet (z-60).
*/
const DialogVeil = () => (
<DialogPortal>
<div
aria-hidden="true"
className="fixed inset-0 z-40 bg-black/50 dark:bg-black/60 animate-in fade-in-0"
/>
</DialogPortal>
)
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end sm:space-x-2 sm:gap-0",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
// data-ph-unmask: dialog titles are static i18n chrome in session replays;
// a title carrying user data adds data-ph-mask at the call site.
<DialogPrimitive.Title
ref={ref}
data-ph-unmask=""
className={cn(
"break-words text-lg leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
data-ph-unmask=""
className={cn("break-words text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogVeil,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}