Files
accounted/lib/hooks/__tests__/detail-pager-guards.test.ts
T
67febd5097 feat(ui): prev/next record navigation on detail pages (#1530)
* feat(ui): prev/next record navigation on detail pages

Customer feedback: stepping between invoices in a reskontra (56 -> 57 ->
58) required going back to the list for every record.

List pages now write their full ordered id array to sessionStorage when
a row is opened (Accounted:list-context:<scope>:<companyId>), and the
detail pages for kundfakturor, leverantorsfakturor, and verifikat show
a compact prev/next pager (chevrons + 'n av m') next to the back
control. ArrowLeft/ArrowRight step too, except while typing in a text
field or while a dialog is open. Navigation uses router.replace so
'tillbaka' returns to the list in one step. Deep links and new tabs
have no context: the pager hides and pages behave as before.

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

* fix(pager): overlay-aware arrow guard, notes-draft safety, context on Visa detaljer

Review fixes on the detail-record pager:

- The keyboard guard matched any mounted [role=dialog], so the agent
  sheet (which stays mounted display:none once opened) killed arrow
  paging for the rest of the tab session, while open dropdown menus
  did not block at all. The guard now mirrors the AgentSheet Esc
  selector (data-state="open" variants incl. alertdialog and radix
  menu/select/listbox content) and also yields while focus sits inside
  a dialog/menu/listbox container. Extracted as pure functions in
  lib/hooks/detail-pager-guards.ts so the rules are testable in the
  node test environment.
- Arrow keys could unmount the verifikat page and destroy an unsaved
  notes draft once the textarea lost focus. useDetailPager and
  DetailPager now take a keyboard flag, and the verifikat page
  disables keyboard paging while editingNotes is active; the chevron
  buttons stay live.
- The expanded-row Visa detaljer link in JournalEntryList navigated
  without writing the list context, producing stale pager snapshots;
  it now calls rememberListContext like the voucher link.
- ListContext.listPath was written and strictly validated but never
  consumed: removed from the interface, all writers, and the read
  validation. Reads stay tolerant of extra properties so contexts
  stored by older builds still parse.

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

* refactor(pager): quiet wayfinding strip instead of buttons in the title cluster

The pager sat between the back arrow and the H1, which read as a toolbar
of three boxed buttons and made the title jump horizontally per record.
All three detail pages now share the verifikat page's pattern: a muted
back text-link on the left and the pager right-aligned on the same quiet
row. The pager itself drops to 16px glyphs, muted ink, and hides when the
list context holds a single record.

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-12 21:07:57 +02:00

200 lines
7.2 KiB
TypeScript

import { describe, it, expect } from 'vitest'
import {
isEditableTarget,
isOverlayBlocking,
resolveArrowKeyAction,
type ArrowKeyEventLike,
type OverlayProbeDocument,
} from '@/lib/hooks/detail-pager-guards'
/**
* Minimal fake DOM for the node test environment: elements are attribute
* bags with an optional parent, and the matcher supports exactly the two
* selector shapes the guard uses: compound attribute selectors
* ([role="dialog"][data-state="open"]) and one-level descendant chains
* ([data-radix-popper-content-wrapper] [data-state="open"]), comma-separated.
*/
interface FakeEl {
attrs: Record<string, string>
parent?: FakeEl
}
function matchesCompound(el: FakeEl, compound: string): boolean {
const attrRe = /\[([a-zA-Z-]+)(?:="([^"]*)")?\]/g
let m: RegExpExecArray | null
let sawAny = false
while ((m = attrRe.exec(compound))) {
sawAny = true
const [, name, value] = m
if (!(name in el.attrs)) return false
if (value !== undefined && el.attrs[name] !== value) return false
}
return sawAny
}
function matchesSelector(el: FakeEl, selector: string): boolean {
const parts = selector.trim().split(/\s+/)
if (!matchesCompound(el, parts[parts.length - 1])) return false
let ancestor = el.parent
for (let i = parts.length - 2; i >= 0; i--) {
let match: FakeEl | undefined
while (ancestor) {
if (matchesCompound(ancestor, parts[i])) {
match = ancestor
break
}
ancestor = ancestor.parent
}
if (!match) return false
ancestor = match.parent
}
return true
}
function matchesAny(el: FakeEl, selectors: string): boolean {
return selectors.split(',').some((s) => matchesSelector(el, s))
}
function makeDoc(elements: FakeEl[], activeElement: FakeEl | null = null): OverlayProbeDocument {
return {
querySelector: (selectors: string) =>
elements.find((el) => matchesAny(el, selectors)) ?? null,
activeElement: activeElement
? {
closest: (selectors: string) => {
let node: FakeEl | undefined = activeElement
while (node) {
if (matchesAny(node, selectors)) return node
node = node.parent
}
return null
},
}
: null,
}
}
function arrowEvent(overrides: Partial<ArrowKeyEventLike> = {}): ArrowKeyEventLike {
return {
key: 'ArrowRight',
defaultPrevented: false,
isComposing: false,
metaKey: false,
ctrlKey: false,
altKey: false,
shiftKey: false,
target: { tagName: 'BODY' },
...overrides,
}
}
const emptyDoc = () => makeDoc([])
describe('isOverlayBlocking', () => {
it('does not block on an empty page', () => {
expect(isOverlayBlocking(emptyDoc())).toBe(false)
})
it('does NOT block on a mounted-but-closed dialog (agent sheet hidden with display:none)', () => {
// AgentSheet renders role="dialog" and stays in the DOM once opened; a
// bare [role="dialog"] query would block paging forever.
const hiddenSheet: FakeEl = { attrs: { role: 'dialog' } }
expect(isOverlayBlocking(makeDoc([hiddenSheet]))).toBe(false)
})
it('blocks on an open dialog', () => {
const openDialog: FakeEl = { attrs: { role: 'dialog', 'data-state': 'open' } }
expect(isOverlayBlocking(makeDoc([openDialog]))).toBe(true)
})
it('blocks on an open alertdialog', () => {
const el: FakeEl = { attrs: { role: 'alertdialog', 'data-state': 'open' } }
expect(isOverlayBlocking(makeDoc([el]))).toBe(true)
})
it('blocks on an open radix dropdown menu (no dialog role)', () => {
const menuContent: FakeEl = {
attrs: { 'data-radix-menu-content': '', 'data-state': 'open' },
}
expect(isOverlayBlocking(makeDoc([menuContent]))).toBe(true)
})
it('blocks on open popper content (select/menu inside the popper wrapper)', () => {
const wrapper: FakeEl = { attrs: { 'data-radix-popper-content-wrapper': '' } }
const content: FakeEl = { attrs: { 'data-state': 'open' }, parent: wrapper }
expect(isOverlayBlocking(makeDoc([content]))).toBe(true)
})
it('does not block on a closed, force-mounted popper', () => {
const wrapper: FakeEl = { attrs: { 'data-radix-popper-content-wrapper': '' } }
const content: FakeEl = { attrs: { 'data-state': 'closed' }, parent: wrapper }
expect(isOverlayBlocking(makeDoc([content]))).toBe(false)
})
it('blocks while focus sits inside a dialog/menu/listbox container', () => {
for (const role of ['dialog', 'menu', 'listbox']) {
const container: FakeEl = { attrs: { role } }
const focused: FakeEl = { attrs: { tabindex: '0' }, parent: container }
expect(isOverlayBlocking(makeDoc([], focused))).toBe(true)
}
})
it('does not block while focus sits in the plain page', () => {
const focused: FakeEl = { attrs: { tabindex: '0' } }
expect(isOverlayBlocking(makeDoc([], focused))).toBe(false)
})
})
describe('isEditableTarget', () => {
it('claims text fields, selects and contentEditable', () => {
expect(isEditableTarget({ tagName: 'INPUT' })).toBe(true)
expect(isEditableTarget({ tagName: 'TEXTAREA' })).toBe(true)
expect(isEditableTarget({ tagName: 'SELECT' })).toBe(true)
expect(isEditableTarget({ tagName: 'DIV', isContentEditable: true })).toBe(true)
})
it('passes on everything else', () => {
expect(isEditableTarget({ tagName: 'BODY' })).toBe(false)
expect(isEditableTarget({ tagName: 'BUTTON' })).toBe(false)
expect(isEditableTarget(null)).toBe(false)
expect(isEditableTarget(undefined)).toBe(false)
})
})
describe('resolveArrowKeyAction', () => {
it('maps plain arrows to paging actions', () => {
expect(resolveArrowKeyAction(arrowEvent({ key: 'ArrowLeft' }), emptyDoc())).toBe('prev')
expect(resolveArrowKeyAction(arrowEvent({ key: 'ArrowRight' }), emptyDoc())).toBe('next')
})
it('ignores other keys', () => {
expect(resolveArrowKeyAction(arrowEvent({ key: 'ArrowUp' }), emptyDoc())).toBeNull()
expect(resolveArrowKeyAction(arrowEvent({ key: 'a' }), emptyDoc())).toBeNull()
})
it('ignores handled or composing events', () => {
expect(resolveArrowKeyAction(arrowEvent({ defaultPrevented: true }), emptyDoc())).toBeNull()
expect(resolveArrowKeyAction(arrowEvent({ isComposing: true }), emptyDoc())).toBeNull()
})
it('ignores modified arrows (browser/OS shortcuts)', () => {
expect(resolveArrowKeyAction(arrowEvent({ metaKey: true }), emptyDoc())).toBeNull()
expect(resolveArrowKeyAction(arrowEvent({ ctrlKey: true }), emptyDoc())).toBeNull()
expect(resolveArrowKeyAction(arrowEvent({ altKey: true }), emptyDoc())).toBeNull()
expect(resolveArrowKeyAction(arrowEvent({ shiftKey: true }), emptyDoc())).toBeNull()
})
it('never steals arrows from a focused text field', () => {
const e = arrowEvent({ target: { tagName: 'TEXTAREA' } })
expect(resolveArrowKeyAction(e, emptyDoc())).toBeNull()
})
it('yields to an open dropdown menu but not to a hidden mounted dialog', () => {
const openMenu = makeDoc([{ attrs: { 'data-radix-menu-content': '', 'data-state': 'open' } }])
expect(resolveArrowKeyAction(arrowEvent(), openMenu)).toBeNull()
const hiddenSheet = makeDoc([{ attrs: { role: 'dialog' } }])
expect(resolveArrowKeyAction(arrowEvent(), hiddenSheet)).toBe('next')
})
})