* 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>
129 lines
4.4 KiB
TypeScript
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)
|
|
})
|
|
})
|