From 834cc4d0e8889e085026fdd5260b31d9c0239bb4 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Thu, 20 Aug 2026 10:03:52 +0200 Subject: [PATCH] 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 * 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 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 --- DECISIONS.md | 1 + components/bookkeeping/AccountCombobox.tsx | 281 +++++++++++++----- .../bookkeeping/CorrectMetadataDialog.tsx | 36 ++- .../bookkeeping/CorrectionEntryDialog.tsx | 74 +++-- components/bookkeeping/RattelseExplainer.tsx | 39 +++ .../bookkeeping/RecordateEntryDialog.tsx | 32 +- components/bookkeeping/StrikeLinesDialog.tsx | 68 +++-- .../account-combobox-position.test.ts | 128 ++++++++ .../bookkeeping/account-combobox-position.ts | 92 ++++++ components/invoices/SendInvoiceDialog.tsx | 21 +- components/ui/dialog.tsx | 18 +- components/ui/help-popover.tsx | 6 +- components/ui/sheet.tsx | 15 +- messages/en.json | 3 +- messages/sv.json | 3 +- scripts/checks/antipatterns-baseline.json | 12 + scripts/checks/no-new-antipatterns.mjs | 139 ++++++++- 17 files changed, 799 insertions(+), 169 deletions(-) create mode 100644 components/bookkeeping/RattelseExplainer.tsx create mode 100644 components/bookkeeping/__tests__/account-combobox-position.test.ts create mode 100644 components/bookkeeping/account-combobox-position.ts diff --git a/DECISIONS.md b/DECISIONS.md index ba522693..a6e878d8 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1085,3 +1085,4 @@ One line per decision: `[YYYY-MM-DD] : `. Appended by agents and [2026-08-19] Bank reconciliation: ignored transactions are excluded from bank_transaction_total/difference (surfaced as separate count+sum) rather than keeping the old "what the bank moved" semantics: the ignore flag's dominant real-world use is feed duplicates (which never moved money and never get a ledger leg), so including them made is_reconciled unreachable after a correct dupe cleanup (observed: permanent 78 867 kr differens on a fully booked EF account). descriptionsBridge now strips ALL whitespace before the prefix compare (collapse-only still misses a dropped space); safe because char-filtering preserves prefix relations and the compare stays inside a (date,ore) bucket. [2026-08-19] Inline rättelse bank guard anchors to the linked bank amount, per account, not to the pre-state and not to the 19xx group net: a non-zero change on a 19xx/cash-ledger account is allowed iff the post-state net on that account equals the signed sum of the linked transactions resolved to it (once per transaction, split links by allocated_amount; NULL cash_account_id resolves to the primary cash account, then 1930). Per account rather than group so a wrong-bank-account booking (1930 vs 1940) stays a storno job: a group check would let the net drift between accounts and break per-account bank reconciliation. When no anchor resolves the old strict refusal stands. Reskontra sides (15xx/24xx) stay strictly net-preserving because their anchor is the payment row, not a bank amount. [2026-08-19] Import mapping step gets a bulk "Bekräfta alla föreslagna" for the VAT-treatment review gate, batching the per-row confirm semantics unchanged (defaults kept, rows marked reviewed): a Fortnox chart routinely puts 70+ class 3/4 accounts behind the gate and the one-click-per-row flow across 50-row pages was an observed live migration dead end (Boltonshield 2026-08-18, stuck at "50 kvar"). Rejected: auto-skipping review for accounts unused by the imported vouchers, because the chart rows are still created with the suggested treatment and a silently wrong default on a soon-used account is exactly what the review gate exists to catch. +[2026-08-19] Dialog overflow hardening: Dialog/Sheet titles and descriptions get break-words at the primitive; AccountCombobox's non-flat dropdown is portaled to document.body with viewport-clamped geometry (same rationale as info-tooltip's TooltipContent portal, since DialogContent's overflow-y-auto otherwise grows a horizontal scrollbar around the 34rem panel); the four rattelse-family dialog explainers are unified behind one RattelseExplainer HelpPopover (convention 7, MatchVoucherDialog precedent) instead of four near-duplicate inline paragraphs; a dialog-overflow-risk ratchet in no-new-antipatterns.mjs keeps bare-1fr tracks, dialog whitespace-nowrap and unportaled wide overlays from coming back. diff --git a/components/bookkeeping/AccountCombobox.tsx b/components/bookkeeping/AccountCombobox.tsx index 448d7375..30bc4b27 100644 --- a/components/bookkeeping/AccountCombobox.tsx +++ b/components/bookkeeping/AccountCombobox.tsx @@ -1,6 +1,7 @@ 'use client' -import { useState, useRef, useEffect, useMemo, useCallback, useId } from 'react' +import { useState, useRef, useEffect, useLayoutEffect, useMemo, useCallback, useId } from 'react' +import { createPortal } from 'react-dom' import { Plus } from 'lucide-react' import { Input } from '@/components/ui/input' import { cn } from '@/lib/utils' @@ -11,8 +12,19 @@ import { type SearchableAccount, type AccountSearchItem, } from '@/lib/bookkeeping/account-search' +import { + computeDropdownPosition, + isSameDropdownPosition, + type DropdownPosition, +} from '@/components/bookkeeping/account-combobox-position' import type { BASAccount } from '@/types' +// Shared by every portaled panel instance: only stops propagation so the +// browser's default scrolling still runs on the panel itself. +function stopScrollPropagation(e: Event) { + e.stopPropagation() +} + interface AccountComboboxProps { value: string accounts: BASAccount[] @@ -59,7 +71,11 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o const [highlightedIndex, setHighlightedIndex] = useState(0) const containerRef = useRef(null) const internalInputRef = useRef(null) - const listRef = useRef(null) + const listRef = useRef(null) + // The portaled (non-flat) dropdown panel. It is not a DOM descendant of + // containerRef, so outside-click detection must check it separately. + const portalPanelRef = useRef(null) + const [dropdownPos, setDropdownPos] = useState(null) const selectedNameId = useId() // Whether the user has typed or arrow-navigated since the field was focused. // Enter only selects the highlighted item after an actual interaction: a @@ -129,10 +145,74 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o } }, [highlightedIndex, isOpen]) + // Keep the portaled (non-flat) dropdown glued to the trigger: measure off + // containerRef when it opens, and re-measure while anything scrolls or the + // window resizes underneath it. The capture-phase scroll listener also + // catches scrolling ancestors such as DialogContent's overflow-y-auto body. + const updateDropdownPosition = useCallback(() => { + if (flat || !containerRef.current) return + const rect = containerRef.current.getBoundingClientRect() + const next = computeDropdownPosition( + { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }, + { width: window.innerWidth, height: window.innerHeight }, + ) + // Bail out when nothing moved (e.g. scroll ticks that did not shift the + // anchor): returning the previous reference lets React skip the re-render. + setDropdownPos((prev) => (isSameDropdownPosition(prev, next) ? prev : next)) + }, [flat]) + + useLayoutEffect(() => { + if (!isOpen || flat) return + updateDropdownPosition() + const handleScroll = (e: Event) => { + // Scrolling the portaled panel's own list never moves the anchor (the + // panel is position: fixed): repositioning on it would just churn state + // while the user scrolls the account list. + if (e.target instanceof Node && portalPanelRef.current?.contains(e.target)) return + updateDropdownPosition() + } + window.addEventListener('scroll', handleScroll, true) + window.addEventListener('resize', updateDropdownPosition) + return () => { + window.removeEventListener('scroll', handleScroll, true) + window.removeEventListener('resize', updateDropdownPosition) + } + }, [isOpen, flat, updateDropdownPosition]) + + // react-remove-scroll (active inside every modal dialog) preventDefaults + // wheel/touchmove events that reach document from outside the dialog's DOM + // tree, and the portaled panel lives outside that tree. Stopping the events + // at the panel lets the browser scroll it natively; overscroll-contain on + // the panel stops chained page scrolling at the list's edges. + const attachPortalPanel = useCallback((el: HTMLDivElement | null) => { + const prev = portalPanelRef.current + if (prev) { + prev.removeEventListener('wheel', stopScrollPropagation) + prev.removeEventListener('touchmove', stopScrollPropagation) + } + portalPanelRef.current = el + if (el) { + el.addEventListener('wheel', stopScrollPropagation) + el.addEventListener('touchmove', stopScrollPropagation) + } + }, []) + + const attachPortalListPanel = useCallback((el: HTMLDivElement | null) => { + listRef.current = el + attachPortalPanel(el) + }, [attachPortalPanel]) + // Close dropdown when clicking/tapping outside useEffect(() => { function handleClickOutside(e: MouseEvent | TouchEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + const target = e.target as Node + // The non-flat dropdown is portaled to document.body, so it is not a + // DOM descendant of containerRef: check the portaled panel too. + if ( + containerRef.current && + !containerRef.current.contains(target) && + !(portalPanelRef.current && portalPanelRef.current.contains(target)) + ) { setIsOpen(false) } } @@ -260,9 +340,96 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o const showSelectedName = Boolean(selectedName && value && search === value) - // In flat mode the dropdown follows the trigger's width instead of forcing - // 34rem: inside a SettingsRow that fixed width would overflow the dialog. - const listWidthClass = flat ? 'w-full min-w-0' : 'min-w-[24rem] w-[max(100%,34rem)]' + // Flat mode keeps the dropdown as an absolute child that follows the + // trigger's width: inside a SettingsRow a fixed width would overflow the + // dialog. The non-flat dropdown is portaled to document.body with an + // explicit viewport-clamped geometry (computeDropdownPosition) instead, so + // a scrollable DialogContent can never clip it or grow a horizontal + // scrollbar around it (the fix info-tooltip.tsx already applies to + // TooltipContent, extended to this dropdown). + const flatListWidthClass = 'w-full min-w-0' + + const portalPanelStyle: React.CSSProperties | undefined = dropdownPos + ? { + left: dropdownPos.left, + width: dropdownPos.width, + maxHeight: dropdownPos.maxHeight, + ...(dropdownPos.top !== undefined + ? { top: dropdownPos.top } + : { bottom: dropdownPos.bottom }), + } + : undefined + + // data-dialog-companion: DialogContent/SheetContent treat a pointerdown + // inside a node carrying this attribute as an inside interaction, so + // clicking the portaled panel never dismisses the dialog hosting it. + const listPanelContent = groupedAccounts.map((group) => ( +
+
+ {group.className} +
+ {group.accounts.map((item) => { + const flatIndex = flatList.indexOf(item) + const isHighlighted = flatIndex === highlightedIndex + return ( + + ) + })} +
+ )) + + const emptyPanelContent = ( + <> +

+ Hittade inget konto som matchar. +

+ {/^\d{4}$/.test(search.trim()) ? ( +

+ Om det är ett giltigt BAS-konto aktiveras det när du bokför. +

+ ) : ( +

+ Kontot kan behöva aktiveras i din kontoplan. +

+ )} + {onCreateAccount && ( + + )} + + ) const triggerProps = { ref: setInputRef, @@ -297,89 +464,55 @@ export default function AccountCombobox({ value, accounts, onChange, onCommit, o ) : null} {/* Dropdown */} - {isOpen && !disabled && flatList.length > 0 && ( + {isOpen && !disabled && flatList.length > 0 && (flat ? (
- {groupedAccounts.map((group) => ( -
-
- {group.className} -
- {group.accounts.map((item) => { - const flatIndex = flatList.indexOf(item) - const isHighlighted = flatIndex === highlightedIndex - return ( - - ) - })} -
- ))} + {listPanelContent}
- )} + ) : ( + dropdownPos && + createPortal( +
+ {listPanelContent} +
, + document.body, + ) + ))} {/* Empty state */} - {isOpen && !disabled && search.trim() && flatList.length === 0 && ( + {isOpen && !disabled && search.trim() && flatList.length === 0 && (flat ? (
-

- Hittade inget konto som matchar. -

- {/^\d{4}$/.test(search.trim()) ? ( -

- Om det är ett giltigt BAS-konto aktiveras det när du bokför. -

- ) : ( -

- Kontot kan behöva aktiveras i din kontoplan. -

- )} - {onCreateAccount && ( - - )} + {emptyPanelContent}
- )} + ) : ( + dropdownPos && + createPortal( +
+ {emptyPanelContent} +
, + document.body, + ) + ))} ) } diff --git a/components/bookkeeping/CorrectMetadataDialog.tsx b/components/bookkeeping/CorrectMetadataDialog.tsx index 296ddce8..f768923d 100644 --- a/components/bookkeeping/CorrectMetadataDialog.tsx +++ b/components/bookkeeping/CorrectMetadataDialog.tsx @@ -6,9 +6,11 @@ import { DialogContent, DialogHeader, DialogTitle, + DialogDescription, DialogFooter, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' +import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { useToast } from '@/components/ui/use-toast' @@ -88,19 +90,31 @@ export default function CorrectMetadataDialog({ entry, open, onOpenChange, onCor - Ändra text eller datum + {/* Convention 7: the how-it-works copy lives behind the "?", not in + the dialog flow. */} +
+ Ändra text eller datum + +

+ Verifikationstexten och datumet kan rättas här utan + ändringsverifikation. +

+

+ Varje rättelse loggas med vem och när, och det ursprungliga + innehållet förblir synligt i verifikatets rättelsehistorik. +

+

+ Om månaden redan är momsdeklarerad kan en datumflytt påverka + den inlämnade deklarationen. +

+
+
+ + Datumet kan bara flyttas inom samma bokföringsperiod: använd + "Flytta till annat datum" för att byta period. +
-
-

- Verifikationstexten och datumet kan rättas utan ändringsverifikation. Rättelsen loggas - med vem och när, och det gamla värdet förblir synligt i rättelsehistoriken. Datumet kan - bara flyttas inom samma bokföringsperiod: använd "Flytta till annat datum" för att - byta period. Om månaden redan är momsdeklarerad kan en datumflytt påverka den inlämnade - deklarationen. -

-
-
diff --git a/components/bookkeeping/CorrectionEntryDialog.tsx b/components/bookkeeping/CorrectionEntryDialog.tsx index bdb5964e..68db6266 100644 --- a/components/bookkeeping/CorrectionEntryDialog.tsx +++ b/components/bookkeeping/CorrectionEntryDialog.tsx @@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import AccountCombobox from '@/components/bookkeeping/AccountCombobox' +import RattelseExplainer from '@/components/bookkeeping/RattelseExplainer' import { AddAccountDialog } from '@/components/bookkeeping/AddAccountDialog' import CorrectionPreview from '@/components/bookkeeping/CorrectionPreview' import { @@ -264,22 +265,31 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor - Skapa ändringsverifikation + {/* Convention 7: the how-it-works copy lives behind the "?", not in + the dialog flow. */} +
+ Skapa ändringsverifikation + +

+ Här skapas automatiskt en stornoverifikation som nollställer + originalet och en ny verifikation med dina rättade uppgifter. + Rättelsen bokförs i samma räkenskapsperiod som originalet: du + hittar den under originalets räkenskapsår. +

+

+ Spårbarheten ligger i stornokedjan: originalet, + stornoverifikationen och ändringsverifikationen förblir synliga + i bokföringen och länkade till varandra. +

+

+ Tar du bort ett konto ur de rättade raderna nollställs det + (stornon återför det). Vill du bara återföra hela verifikatet + utan att ersätta det, använd Återför (storno) istället. +

+
+
- {/* Storno explanation */} -
-

Hur fungerar en ändringsverifikation?

-

En bokförd verifikation kan inte ändras direkt. Istället skapas automatiskt:

-
    -
  1. En stornoverifikation som nollställer den ursprungliga
  2. -
  3. En ny verifikation med dina rättade uppgifter
  4. -
-

- Rättelsen bokförs i samma räkenskapsperiod som originalet: du hittar den under originalets räkenskapsår. -

-
- {/* Original entry metadata: lines live inside CorrectionPreview below */}
@@ -318,9 +328,7 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor

Rättade rader

- Det här är hela den nya verifikationen: alla konton som ska finnas kvar måste stå - kvar. Tar du bort ett konto nollställs det (stornon återför det). Vill du bara återföra - hela verifikatet utan att ersätta det, använd Återför (storno) istället. + Det här är hela den nya verifikationen: alla konton som ska finnas kvar måste stå kvar.

@@ -340,19 +348,25 @@ export default function CorrectionEntryDialog({ entry, open, onOpenChange, onCor
{lines.map((line, index) => ( -
-
- updateLineAccount(index, v)} - onCreateAccount={(prefill) => { - setCreatingAccountForLine(index) - setCreateAccountPrefill(prefill) - }} - disabled={accountsStatus !== 'ready'} - /> +
+
+ {/* min-w-0: at sm: the sm:contents wrapper promotes this cell + to a direct grid item; without it the combobox refuses to + shrink below its content and overflows the dialog (same + pattern as SendInvoiceDialog's desktop rows). */} +
+ updateLineAccount(index, v)} + onCreateAccount={(prefill) => { + setCreatingAccountForLine(index) + setCreateAccountPrefill(prefill) + }} + disabled={accountsStatus !== 'ready'} + /> +