Files
accounted/components/bookkeeping/__tests__/account-combobox-position.test.ts
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

129 lines
4.4 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
computeDropdownPosition,
isSameDropdownPosition,
DROPDOWN_PREFERRED_WIDTH,
DROPDOWN_VIEWPORT_MARGIN,
DROPDOWN_ANCHOR_GAP,
DROPDOWN_MAX_HEIGHT,
} from '../account-combobox-position'
const desktop = { width: 1440, height: 900 }
describe('computeDropdownPosition', () => {
it('opens below the anchor at the preferred width when there is room', () => {
const pos = computeDropdownPosition(
{ top: 200, bottom: 232, left: 300, width: 160 },
desktop,
)
expect(pos.top).toBe(232 + DROPDOWN_ANCHOR_GAP)
expect(pos.bottom).toBeUndefined()
expect(pos.left).toBe(300)
expect(pos.width).toBe(DROPDOWN_PREFERRED_WIDTH)
expect(pos.maxHeight).toBe(DROPDOWN_MAX_HEIGHT)
})
it('keeps the anchor width when the trigger is wider than the preferred width', () => {
const pos = computeDropdownPosition(
{ top: 200, bottom: 232, left: 100, width: 700 },
desktop,
)
expect(pos.width).toBe(700)
})
it('clamps left so the panel never crosses the right viewport edge', () => {
const pos = computeDropdownPosition(
{ top: 200, bottom: 232, left: 1200, width: 160 },
desktop,
)
expect(pos.left + pos.width).toBe(desktop.width - DROPDOWN_VIEWPORT_MARGIN)
})
it('never puts the panel past the left margin', () => {
const pos = computeDropdownPosition(
{ top: 200, bottom: 232, left: 2, width: 160 },
desktop,
)
expect(pos.left).toBe(DROPDOWN_VIEWPORT_MARGIN)
})
it('shrinks to the viewport on a narrow (mobile) screen', () => {
const mobile = { width: 375, height: 700 }
const pos = computeDropdownPosition(
{ top: 100, bottom: 140, left: 16, width: 200 },
mobile,
)
expect(pos.width).toBe(375 - DROPDOWN_VIEWPORT_MARGIN * 2)
expect(pos.left).toBe(DROPDOWN_VIEWPORT_MARGIN)
})
it('caps maxHeight to the space below the anchor', () => {
const pos = computeDropdownPosition(
{ top: 650, bottom: 682, left: 300, width: 160 },
desktop,
)
expect(pos.top).toBe(682 + DROPDOWN_ANCHOR_GAP)
expect(pos.maxHeight).toBe(
desktop.height - 682 - DROPDOWN_ANCHOR_GAP - DROPDOWN_VIEWPORT_MARGIN,
)
})
it('flips above the anchor when the space below is too small', () => {
const pos = computeDropdownPosition(
{ top: 800, bottom: 832, left: 300, width: 160 },
desktop,
)
expect(pos.top).toBeUndefined()
expect(pos.bottom).toBe(desktop.height - 800 + DROPDOWN_ANCHOR_GAP)
expect(pos.maxHeight).toBe(DROPDOWN_MAX_HEIGHT)
})
it('stays below when the space above is even smaller than below', () => {
const shortViewport = { width: 1440, height: 220 }
const pos = computeDropdownPosition(
{ top: 40, bottom: 72, left: 300, width: 160 },
shortViewport,
)
expect(pos.top).toBe(72 + DROPDOWN_ANCHOR_GAP)
// Cramped viewport: the height floor keeps the list usable and scrollable.
expect(pos.maxHeight).toBeGreaterThanOrEqual(96)
})
})
describe('isSameDropdownPosition', () => {
const anchor = { top: 200, bottom: 232, left: 300, width: 160 }
it('is false against null (no previous position)', () => {
expect(isSameDropdownPosition(null, computeDropdownPosition(anchor, desktop))).toBe(false)
})
it('is true for two computations off an unmoved anchor', () => {
const a = computeDropdownPosition(anchor, desktop)
const b = computeDropdownPosition({ ...anchor }, desktop)
expect(isSameDropdownPosition(a, b)).toBe(true)
})
it.each([
['left', { left: 301 }],
['width', { width: 545 }],
['maxHeight', { maxHeight: 299 }],
['top', { top: 237 }],
] as const)('is false when %s differs', (_field, patch) => {
const a = computeDropdownPosition(anchor, desktop)
expect(isSameDropdownPosition(a, { ...a, ...patch })).toBe(false)
})
it('distinguishes open-below from open-above at the same coordinates', () => {
const below = computeDropdownPosition(anchor, desktop)
const above = computeDropdownPosition({ top: 800, bottom: 832, left: 300, width: 160 }, desktop)
expect(below.top).toBeDefined()
expect(above.bottom).toBeDefined()
expect(isSameDropdownPosition(below, above)).toBe(false)
// Flipping sides toggles which of top/bottom is undefined: the guard must
// compare both, not just the defined one.
expect(
isSameDropdownPosition(below, { ...below, top: undefined, bottom: 123 }),
).toBe(false)
})
})