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>
This commit is contained in:
Jakob Wennberg
2026-08-12 21:07:57 +02:00
committed by GitHub
parent bffa57a565
commit 67febd5097
14 changed files with 810 additions and 20 deletions
+23 -8
View File
@@ -37,6 +37,9 @@ import { Badge } from '@/components/ui/badge'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { fetchDimensions, type DimensionDto } from '@/components/dimensions/types'
import { DetailPager } from '@/components/common/DetailPager'
import { listContextKey } from '@/lib/navigation/list-context'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type { JournalEntry, JournalEntryLine } from '@/types'
import type { UnderlagReference } from '@/lib/core/bookkeeping/journal-entry-references'
@@ -67,6 +70,7 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
const { id } = use(params)
const router = useRouter()
const { canWrite } = useCanWrite()
const company = useCompanyOptional()?.company ?? null
const { toast } = useToast()
const t = useTranslations('journal_detail')
const sourceTypeLabels = useSourceTypeLabels()
@@ -409,14 +413,25 @@ export default function JournalEntryDetailPage({ params }: { params: Promise<{ i
return (
<div className="space-y-8">
{/* Back link */}
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back')}
</Link>
{/* Back link + prev/next record pager */}
<div className="flex items-center justify-between gap-4">
<Link
href="/bookkeeping"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back')}
</Link>
<DetailPager
contextKey={listContextKey('bookkeeping', company?.id)}
basePath="/bookkeeping"
currentId={id}
// Arrow paging unmounts the page and would destroy an unsaved notes
// draft; the textarea only guards arrows while it has focus, so gate
// the keyboard bindings on the editing state itself.
keyboard={!editingNotes}
/>
</div>
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
+20 -3
View File
@@ -58,6 +58,8 @@ import {
type InvoiceDeliveryView,
} from '@/components/invoices/InvoiceDeliveryHistory'
import CorrectionAffordance from '@/components/bookkeeping/CorrectionAffordance'
import { DetailPager } from '@/components/common/DetailPager'
import { listContextKey } from '@/lib/navigation/list-context'
import {
Dialog,
DialogContent,
@@ -855,12 +857,27 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
)
return (
<div className="space-y-8">
{/* Back link + prev/next record pager on their own quiet row, so the
title below keeps a stable position while stepping between records */}
<div className="flex items-center justify-between gap-4">
<button
type="button"
onClick={() => router.back()}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back')}
</button>
<DetailPager
contextKey={listContextKey('invoices', company?.id)}
basePath="/invoices"
currentId={id}
/>
</div>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-4">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className={cn('font-display text-2xl leading-8 tracking-tight', !invoice.invoice_number && !isSelfBilled && 'italic text-muted-foreground')}>{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : (invoice.invoice_number ?? '-')}</h1>
+17 -2
View File
@@ -33,6 +33,7 @@ import {
type InvoiceListSort,
type InvoiceListSortColumn,
} from '@/lib/invoices/invoice-list-sort'
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
import {
ArrowDown,
ArrowUp,
@@ -369,6 +370,14 @@ export default function InvoicesPage() {
)
const visibleInvoices = sortedInvoices.slice(0, visibleCount)
// Detail-pager context: the FULL sorted list (not the visible slice), so
// prev/next on the detail page can walk past the paging boundary.
const rememberListContext = () => {
writeListContext(listContextKey('invoices', company?.id), {
ids: sortedInvoices.map((invoice) => invoice.id),
})
}
const tabCounts = useMemo(() => {
const counts = Object.fromEntries(ALL_TABS.map((tab) => [tab, 0])) as Record<ListTab, number>
for (const invoice of scopedInvoices) {
@@ -772,7 +781,10 @@ export default function InvoicesPage() {
'group cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
selectedIds.has(invoice.id) && 'bg-secondary/40',
)}
onClick={() => router.push(`/invoices/${invoice.id}`)}
onClick={() => {
rememberListContext()
router.push(`/invoices/${invoice.id}`)
}}
>
{/* Hover-revealed selection checkbox (supplier-invoices shape). */}
{showSelection && (
@@ -799,7 +811,10 @@ export default function InvoicesPage() {
<Link
href={`/invoices/${invoice.id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
rememberListContext()
}}
>
{number ?? '·'}
</Link>
@@ -27,6 +27,9 @@ import { useCompanySettings } from '@/components/settings/useSettings'
import { formatAmount, formatCurrency } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
import { DetailPager } from '@/components/common/DetailPager'
import { listContextKey } from '@/lib/navigation/list-context'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, BASAccount } from '@/types'
interface EditableLine {
@@ -82,8 +85,10 @@ export default function SupplierInvoiceDetailPage() {
const { settings: companySettings } = useCompanySettings()
const params = useParams()
const router = useRouter()
const company = useCompanyOptional()?.company ?? null
const { toast } = useToast()
const t = useTranslations('supplier_invoice_detail')
const tCommon = useTranslations('common')
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
@@ -472,12 +477,29 @@ export default function SupplierInvoiceDetailPage() {
return (
<div className="space-y-8 max-w-4xl">
{/* Back link + prev/next record pager on their own quiet row, so the
title below keeps a stable position while stepping between records */}
<div className="flex items-center justify-between gap-4">
<button
type="button"
onClick={() => router.push('/supplier-invoices')}
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
aria-label={t('back_aria')}
>
<ArrowLeft className="h-4 w-4" />
{tCommon('back')}
</button>
<DetailPager
contextKey={listContextKey('supplier-invoices', company?.id)}
basePath="/supplier-invoices"
currentId={String(params.id)}
className="shrink-0"
/>
</div>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
<Button variant="ghost" size="icon" className="shrink-0" onClick={() => router.push('/supplier-invoices')} aria-label={t('back_aria')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className="font-display text-2xl leading-8 tracking-tight">
+19 -2
View File
@@ -23,6 +23,8 @@ import { getErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getDisplayTotal } from '@/lib/invoices/rounding'
import { canApproveSupplierInvoice } from '@/lib/supplier-invoices/lifecycle'
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import type { FiscalPeriod, SupplierInvoice } from '@/types'
const NewSupplierInvoiceDialog = dynamic(
@@ -90,6 +92,7 @@ export default function SupplierInvoicesPage() {
const { toast } = useToast()
const router = useRouter()
const searchParams = useSearchParams()
const company = useCompanyOptional()?.company ?? null
const [invoices, setInvoices] = useState<(SupplierInvoice & { supplier?: { id: string; name: string } })[]>([])
const [isLoading, setIsLoading] = useState(true)
const [activeTab, setActiveTab] = useState<ListTab>('all')
@@ -185,6 +188,14 @@ export default function SupplierInvoicesPage() {
return matchesTab && matchesSearch && matchesFy
})
// Detail-pager context: the filtered list as rendered, written when the
// user navigates into a row.
const rememberListContext = () => {
writeListContext(listContextKey('supplier-invoices', company?.id), {
ids: filteredInvoices.map((inv) => inv.id),
})
}
const registeredCount = invoices.filter((inv) => inv.status === 'registered').length
const toPayCount = invoices.filter(
(inv) => inv.status === 'registered' || inv.status === 'approved' || inv.status === 'overdue',
@@ -423,7 +434,10 @@ export default function SupplierInvoicesPage() {
'group cursor-pointer transition-colors duration-150 hover:bg-secondary/35',
selectedIds.has(inv.id) && 'bg-secondary/40',
)}
onClick={() => router.push(`/supplier-invoices/${inv.id}`)}
onClick={() => {
rememberListContext()
router.push(`/supplier-invoices/${inv.id}`)
}}
>
{/* Hover-revealed selection checkbox (JournalEntryList shape). */}
{canWrite && (
@@ -453,7 +467,10 @@ export default function SupplierInvoicesPage() {
<Link
href={`/supplier-invoices/${inv.id}`}
className="hover:underline"
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
rememberListContext()
}}
>
{inv.supplier_invoice_number}
</Link>
+18 -2
View File
@@ -56,6 +56,7 @@ import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { listContextKey, writeListContext } from '@/lib/navigation/list-context'
import type { FiscalPeriod, JournalEntry, JournalEntryLine } from '@/types'
const NEEDS_ATTACHMENT = new Set([
@@ -799,6 +800,14 @@ export default function JournalEntryList() {
)
: entries
// Detail-pager context: the loaded page as rendered, written when the user
// opens a verifikat. Server-paginated, so prev/next spans this page only.
const rememberListContext = () => {
writeListContext(listContextKey('bookkeeping', company?.id), {
ids: filteredEntries.map((e) => e.id),
})
}
// Count of active dialog filters, shown as a badge on the Filtrera button so
// the user can tell the list is scoped without opening the dialog. Sort order
// is a view preference (always set), not a filter, so it is excluded.
@@ -1399,7 +1408,10 @@ export default function JournalEntryList() {
'font-mono text-[13px] tabular-nums hover:underline',
struckCell,
)}
onClick={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation()
rememberListContext()
}}
// The row's Enter/Space handler calls
// preventDefault(), so without this the voucher
// link expands the row instead of opening it.
@@ -1631,7 +1643,11 @@ export default function JournalEntryList() {
{t('post')}
</Button>
)}
<Link href={`/bookkeeping/${entry.id}`} className={QUIET_LINK_CLASS}>
<Link
href={`/bookkeeping/${entry.id}`}
className={QUIET_LINK_CLASS}
onClick={rememberListContext}
>
{t('show_details')}
</Link>
{entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && (
+67
View File
@@ -0,0 +1,67 @@
'use client'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useDetailPager } from '@/lib/hooks/use-detail-pager'
interface DetailPagerProps {
/** Storage key from listContextKey(), matching what the list page wrote. */
contextKey: string
/** Detail route prefix without trailing slash, e.g. '/invoices'. */
basePath: string
currentId: string
/**
* Set false to suspend arrow-key paging, e.g. while an inline editor with
* unsaved state is open. The chevron buttons stay active.
*/
keyboard?: boolean
className?: string
}
/**
* Quiet prev/next wayfinding for detail pages: two muted chevrons around an
* "n av m" position. Belongs on the back-link row (right-aligned), never
* inside the title cluster, so the heading keeps a stable position while
* stepping. Renders nothing when no list context exists (deep link or new
* tab) or when the list holds a single record, so pages degrade gracefully.
*/
export function DetailPager({
contextKey,
basePath,
currentId,
keyboard = true,
className,
}: DetailPagerProps) {
const t = useTranslations('common')
const pager = useDetailPager(contextKey, basePath, currentId, { keyboard })
if (pager.index === null || pager.total === null || pager.total < 2) return null
return (
<div className={cn('flex items-center', className)}>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={pager.goPrev}
disabled={!pager.prevId}
aria-label={t('pager_previous')}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<span className="min-w-12 whitespace-nowrap px-1 text-center text-xs tabular-nums text-muted-foreground">
{t('pager_position', { index: pager.index, total: pager.total })}
</span>
<Button
variant="ghost"
size="icon"
className="text-muted-foreground hover:text-foreground"
onClick={pager.goNext}
disabled={!pager.nextId}
aria-label={t('pager_next')}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
)
}
@@ -0,0 +1,199 @@
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')
})
})
+93
View File
@@ -0,0 +1,93 @@
/**
* Pure keyboard-guard rules for useDetailPager, extracted from the hook so
* they are testable in the node test environment (the hook itself needs a
* browser to render). Only structural types: the hook passes the real
* document, tests pass plain objects.
*/
/**
* Overlays that are actually open. Mirrors the Esc guard selector in
* components/agent/AgentSheet.tsx: match on data-state="open", never on the
* container alone. The agent sheet renders role="dialog" and stays mounted
* with display:none once it has been opened, so keying off a bare
* [role="dialog"] would kill arrow paging for the rest of the tab session;
* conversely open dropdown menus (Radix menu/select/listbox content) carry
* no dialog role and must still block.
*/
const OPEN_OVERLAY_SELECTOR = [
'[data-radix-popper-content-wrapper] [data-state="open"]',
'[role="dialog"][data-state="open"]',
'[role="alertdialog"][data-state="open"]',
'[role="listbox"][data-state="open"]',
'[data-radix-menu-content][data-state="open"]',
'[data-radix-select-content][data-state="open"]',
].join(', ')
/**
* Containers whose focused content owns the arrow keys regardless of
* data-state, for overlays that do not mark themselves the Radix way.
* A display:none container can never hold focus, so a mounted-but-hidden
* sheet does not block through this path either.
*/
const FOCUS_CONTAINER_SELECTOR =
'[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]'
export interface OverlayProbeElement {
closest(selectors: string): unknown
}
export interface OverlayProbeDocument {
querySelector(selectors: string): unknown
readonly activeElement: OverlayProbeElement | null
}
/** True while an overlay that should own the keyboard is open or focused. */
export function isOverlayBlocking(doc: OverlayProbeDocument): boolean {
if (doc.querySelector(OPEN_OVERLAY_SELECTOR)) return true
const active = doc.activeElement
return Boolean(active?.closest(FOCUS_CONTAINER_SELECTOR))
}
/**
* True for targets that own arrow keys: text fields, selects, contentEditable.
* Duck-typed on tagName/isContentEditable instead of instanceof HTMLElement so
* the rule works without DOM globals (tests, SSR); the semantics are the same
* for real elements.
*/
export function isEditableTarget(target: unknown): boolean {
if (target === null || typeof target !== 'object') return false
const el = target as { tagName?: unknown; isContentEditable?: unknown }
if (el.isContentEditable === true) return true
return el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.tagName === 'SELECT'
}
export interface ArrowKeyEventLike {
key: string
defaultPrevented: boolean
isComposing: boolean
metaKey: boolean
ctrlKey: boolean
altKey: boolean
shiftKey: boolean
target: unknown
}
/**
* The full keydown decision: which paging action, if any, a keydown event
* should trigger. Returns null whenever anything nearer the user owns the key.
*/
export function resolveArrowKeyAction(
e: ArrowKeyEventLike,
doc: OverlayProbeDocument,
): 'prev' | 'next' | null {
if (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight') return null
if (e.defaultPrevented || e.isComposing) return null
// Plain arrows only: modified arrows are browser/OS shortcuts
// (cmd+arrow is history navigation on macOS).
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return null
// Never steal arrows from text editing (e.g. the notes textarea on the
// verifikat page).
if (isEditableTarget(e.target)) return null
if (isOverlayBlocking(doc)) return null
return e.key === 'ArrowLeft' ? 'prev' : 'next'
}
+93
View File
@@ -0,0 +1,93 @@
'use client'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import {
computeNeighbors,
readListContext,
type ListContext,
} from '@/lib/navigation/list-context'
import { resolveArrowKeyAction } from '@/lib/hooks/detail-pager-guards'
export interface DetailPager {
prevId: string | null
nextId: string | null
/** 1-based position, null when no list context exists (deep link/new tab). */
index: number | null
total: number | null
goPrev: () => void
goNext: () => void
}
export interface DetailPagerOptions {
/**
* Set false to suspend the ArrowLeft/ArrowRight bindings entirely, e.g.
* while an inline editor with unsaved state is open (paging unmounts the
* page and would destroy the draft). Buttons stay active.
*/
keyboard?: boolean
}
/**
* Prev/next record navigation for detail pages, backed by the list context
* the originating list page wrote to sessionStorage (lib/navigation/
* list-context.ts). Also binds ArrowLeft/ArrowRight while no text field or
* open overlay (dialog, menu, listbox) owns the keys; see
* lib/hooks/detail-pager-guards.ts for the exact rules. When no context
* exists everything is null and the pager UI hides; the detail page degrades
* gracefully.
*/
export function useDetailPager(
contextKey: string,
basePath: string,
currentId: string,
options?: DetailPagerOptions,
): DetailPager {
const keyboard = options?.keyboard ?? true
const router = useRouter()
const [context, setContext] = useState<ListContext | null>(null)
// Read in an effect, not during render: sessionStorage does not exist on
// the server and reading it pre-hydration would mismatch the SSR HTML.
useEffect(() => {
setContext(readListContext(contextKey))
}, [contextKey])
const neighbors = useMemo(
() => (context ? computeNeighbors(context.ids, currentId) : null),
[context, currentId],
)
const prevId = neighbors?.prevId ?? null
const nextId = neighbors?.nextId ?? null
// router.replace, deliberately not push: stepping through records is one
// browsing act, so "tillbaka" returns to the list in a single step instead
// of walking back through every viewed record.
const goPrev = useCallback(() => {
if (prevId) router.replace(`${basePath}/${prevId}`)
}, [basePath, prevId, router])
const goNext = useCallback(() => {
if (nextId) router.replace(`${basePath}/${nextId}`)
}, [basePath, nextId, router])
useEffect(() => {
if (!neighbors || !keyboard) return
const onKeyDown = (e: KeyboardEvent) => {
const action = resolveArrowKeyAction(e, document)
if (action === 'prev') goPrev()
else if (action === 'next') goNext()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [neighbors, keyboard, goPrev, goNext])
return {
prevId,
nextId,
index: neighbors?.index ?? null,
total: neighbors?.total ?? null,
goPrev,
goNext,
}
}
@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest'
import {
computeNeighbors,
listContextKey,
readListContext,
writeListContext,
type ListContext,
} from '@/lib/navigation/list-context'
function makeStorage(initial: Record<string, string> = {}) {
const map = new Map<string, string>(Object.entries(initial))
return {
getItem: (key: string) => map.get(key) ?? null,
setItem: (key: string, value: string) => {
map.set(key, value)
},
}
}
describe('listContextKey', () => {
it('builds a company-scoped key', () => {
expect(listContextKey('invoices', 'c-1')).toBe('Accounted:list-context:invoices:c-1')
})
it('falls back to default when no company id exists', () => {
expect(listContextKey('invoices', null)).toBe('Accounted:list-context:invoices:default')
expect(listContextKey('invoices', undefined)).toBe('Accounted:list-context:invoices:default')
})
})
describe('writeListContext / readListContext', () => {
it('round-trips a context', () => {
const storage = makeStorage()
const context: ListContext = { ids: ['a', 'b', 'c'] }
writeListContext('key', context, storage)
expect(readListContext('key', storage)).toEqual(context)
})
it('returns null when nothing was written', () => {
expect(readListContext('missing', makeStorage())).toBeNull()
})
it('returns null on garbage JSON', () => {
const storage = makeStorage({ key: '{not json' })
expect(readListContext('key', storage)).toBeNull()
})
it('returns null on valid JSON with the wrong shape', () => {
expect(readListContext('key', makeStorage({ key: '"a string"' }))).toBeNull()
expect(readListContext('key', makeStorage({ key: 'null' }))).toBeNull()
expect(readListContext('key', makeStorage({ key: '{"ids":"nope"}' }))).toBeNull()
expect(readListContext('key', makeStorage({ key: '{"ids":["a",1]}' }))).toBeNull()
expect(readListContext('key', makeStorage({ key: '{"other":["a"]}' }))).toBeNull()
})
it('tolerates extra properties, so contexts stored by older builds still parse', () => {
// Older builds wrote a listPath field alongside ids.
const stored = makeStorage({ key: '{"ids":["a","b"],"listPath":"/invoices"}' })
expect(readListContext('key', stored)).toEqual({ ids: ['a', 'b'] })
})
it('keeps keys isolated from each other', () => {
const storage = makeStorage()
writeListContext(listContextKey('invoices', 'c-1'), { ids: ['inv-1'] }, storage)
writeListContext(listContextKey('supplier-invoices', 'c-1'), { ids: ['sup-1'] }, storage)
writeListContext(listContextKey('invoices', 'c-2'), { ids: ['inv-9'] }, storage)
expect(readListContext(listContextKey('invoices', 'c-1'), storage)?.ids).toEqual(['inv-1'])
expect(readListContext(listContextKey('supplier-invoices', 'c-1'), storage)?.ids).toEqual([
'sup-1',
])
expect(readListContext(listContextKey('invoices', 'c-2'), storage)?.ids).toEqual(['inv-9'])
})
it('does not throw when storage is unavailable', () => {
expect(() => writeListContext('key', { ids: [] }, null)).not.toThrow()
expect(readListContext('key', null)).toBeNull()
})
it('swallows setItem failures (quota exceeded)', () => {
const storage = {
getItem: () => null,
setItem: () => {
throw new Error('QuotaExceededError')
},
}
expect(() => writeListContext('key', { ids: ['a'] }, storage)).not.toThrow()
})
})
describe('computeNeighbors', () => {
const ids = ['a', 'b', 'c', 'd']
it('finds both neighbors in the middle of the list', () => {
expect(computeNeighbors(ids, 'b')).toEqual({
prevId: 'a',
nextId: 'c',
index: 2,
total: 4,
})
})
it('has no prev at the start', () => {
expect(computeNeighbors(ids, 'a')).toEqual({
prevId: null,
nextId: 'b',
index: 1,
total: 4,
})
})
it('has no next at the end', () => {
expect(computeNeighbors(ids, 'd')).toEqual({
prevId: 'c',
nextId: null,
index: 4,
total: 4,
})
})
it('handles a single-record list', () => {
expect(computeNeighbors(['only'], 'only')).toEqual({
prevId: null,
nextId: null,
index: 1,
total: 1,
})
})
it('returns null when the record is not in the context (stale/foreign context)', () => {
expect(computeNeighbors(ids, 'zzz')).toBeNull()
expect(computeNeighbors([], 'a')).toBeNull()
})
})
+97
View File
@@ -0,0 +1,97 @@
/**
* Session-scoped list context for prev/next record navigation on detail pages.
*
* List pages write the full ordered id array (post filter/sort, not just the
* visible slice) when the user navigates into a record; the detail page reads
* it back to offer "bladdra" between records without returning to the list.
*
* sessionStorage on purpose: the context is a per-tab browsing session. A deep
* link or a new tab has no context and the pager simply does not render.
* Client-side lists sort with rules that are not expressible server-side
* (e.g. lib/invoices/invoice-list-sort.ts uses an sv Intl.Collator with
* display fallbacks), so the id order must be captured where it was computed.
*/
export interface ListContext {
/** Ordered record ids exactly as the list presented them. */
ids: string[]
}
export interface ListNeighbors {
prevId: string | null
nextId: string | null
/** 1-based position of the current record, for "n av m" display. */
index: number
total: number
}
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>
function defaultStorage(): StorageLike | null {
// Guarded twice: window is absent during SSR, and accessing sessionStorage
// itself can throw (disabled storage / strict privacy modes).
try {
if (typeof window === 'undefined') return null
return window.sessionStorage
} catch {
return null
}
}
/** Builds the per-company storage key, e.g. 'Accounted:list-context:invoices:<companyId>'. */
export function listContextKey(scope: string, companyId: string | null | undefined): string {
return `Accounted:list-context:${scope}:${companyId ?? 'default'}`
}
export function writeListContext(
key: string,
context: ListContext,
storage: StorageLike | null = defaultStorage(),
): void {
if (!storage) return
try {
storage.setItem(key, JSON.stringify(context))
} catch {
// Quota exceeded or storage blocked: the pager just won't appear on the
// detail page. Row navigation itself must never fail on this.
}
}
export function readListContext(
key: string,
storage: StorageLike | null = defaultStorage(),
): ListContext | null {
if (!storage) return null
try {
const raw = storage.getItem(key)
if (!raw) return null
const parsed: unknown = JSON.parse(raw)
if (typeof parsed !== 'object' || parsed === null) return null
const candidate = parsed as { ids?: unknown }
if (!Array.isArray(candidate.ids)) return null
if (candidate.ids.some((id) => typeof id !== 'string')) return null
// Deliberately tolerant of extra properties (e.g. the listPath field
// older builds wrote): only ids is consumed, and previously stored
// contexts must keep parsing.
return { ids: candidate.ids as string[] }
} catch {
// Garbage in storage (manual edits, older shapes): behave as no context.
return null
}
}
/**
* Pure neighbor computation for the detail pager. Returns null when the
* current record is not part of the context (stale context after a delete,
* or a context written from another list).
*/
export function computeNeighbors(ids: string[], currentId: string): ListNeighbors | null {
const position = ids.indexOf(currentId)
if (position === -1) return null
return {
prevId: position > 0 ? ids[position - 1] : null,
nextId: position < ids.length - 1 ? ids[position + 1] : null,
index: position + 1,
total: ids.length,
}
}
+3
View File
@@ -14,6 +14,9 @@
"back": "Back",
"next": "Next",
"previous": "Previous",
"pager_previous": "Previous record",
"pager_next": "Next record",
"pager_position": "{index} of {total}",
"close": "Close",
"search": "Search",
"filter": "Filter",
+3
View File
@@ -14,6 +14,9 @@
"back": "Tillbaka",
"next": "Nästa",
"previous": "Föregående",
"pager_previous": "Föregående post",
"pager_next": "Nästa post",
"pager_position": "{index} av {total}",
"close": "Stäng",
"search": "Sök",
"filter": "Filtrera",