diff --git a/app/(dashboard)/bookkeeping/[id]/page.tsx b/app/(dashboard)/bookkeeping/[id]/page.tsx index 70cb4f6c..8f21e89c 100644 --- a/app/(dashboard)/bookkeeping/[id]/page.tsx +++ b/app/(dashboard)/bookkeeping/[id]/page.tsx @@ -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 (
- {/* Back link */} - - - {t('back')} - + {/* Back link + prev/next record pager */} +
+ + + {t('back')} + + +
{/* Header */}
diff --git a/app/(dashboard)/invoices/[id]/page.tsx b/app/(dashboard)/invoices/[id]/page.tsx index c49d7e4e..3b5bbe92 100644 --- a/app/(dashboard)/invoices/[id]/page.tsx +++ b/app/(dashboard)/invoices/[id]/page.tsx @@ -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 (
+ {/* Back link + prev/next record pager on their own quiet row, so the + title below keeps a stable position while stepping between records */} +
+ + +
+ {/* Header */}
-

{isSelfBilled ? invoiceDisplayNumber(invoice as Invoice) : (invoice.invoice_number ?? '-')}

diff --git a/app/(dashboard)/invoices/page.tsx b/app/(dashboard)/invoices/page.tsx index f5701b1e..376b1a84 100644 --- a/app/(dashboard)/invoices/page.tsx +++ b/app/(dashboard)/invoices/page.tsx @@ -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 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() { e.stopPropagation()} + onClick={(e) => { + e.stopPropagation() + rememberListContext() + }} > {number ?? '·'} diff --git a/app/(dashboard)/supplier-invoices/[id]/page.tsx b/app/(dashboard)/supplier-invoices/[id]/page.tsx index 4295bba4..72627ecd 100644 --- a/app/(dashboard)/supplier-invoices/[id]/page.tsx +++ b/app/(dashboard)/supplier-invoices/[id]/page.tsx @@ -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(null) const [isLoading, setIsLoading] = useState(true) const [isPayDialogOpen, setIsPayDialogOpen] = useState(false) @@ -472,12 +477,29 @@ export default function SupplierInvoiceDetailPage() { return (
+ {/* Back link + prev/next record pager on their own quiet row, so the + title below keeps a stable position while stepping between records */} +
+ + +
+ {/* Header */}
-

diff --git a/app/(dashboard)/supplier-invoices/page.tsx b/app/(dashboard)/supplier-invoices/page.tsx index 0ccbd20b..1ac23541 100644 --- a/app/(dashboard)/supplier-invoices/page.tsx +++ b/app/(dashboard)/supplier-invoices/page.tsx @@ -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('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() { e.stopPropagation()} + onClick={(e) => { + e.stopPropagation() + rememberListContext() + }} > {inv.supplier_invoice_number} diff --git a/components/bookkeeping/JournalEntryList.tsx b/components/bookkeeping/JournalEntryList.tsx index 814a5bea..b4df34ef 100644 --- a/components/bookkeeping/JournalEntryList.tsx +++ b/components/bookkeeping/JournalEntryList.tsx @@ -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')} )} - + {t('show_details')} {entry.status === 'posted' && entry.source_type !== 'storno' && entry.source_type !== 'correction' && ( diff --git a/components/common/DetailPager.tsx b/components/common/DetailPager.tsx new file mode 100644 index 00000000..954d373f --- /dev/null +++ b/components/common/DetailPager.tsx @@ -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 ( +
+ + + {t('pager_position', { index: pager.index, total: pager.total })} + + +
+ ) +} diff --git a/lib/hooks/__tests__/detail-pager-guards.test.ts b/lib/hooks/__tests__/detail-pager-guards.test.ts new file mode 100644 index 00000000..65df51a2 --- /dev/null +++ b/lib/hooks/__tests__/detail-pager-guards.test.ts @@ -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 + 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 { + 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') + }) +}) diff --git a/lib/hooks/detail-pager-guards.ts b/lib/hooks/detail-pager-guards.ts new file mode 100644 index 00000000..b44f481f --- /dev/null +++ b/lib/hooks/detail-pager-guards.ts @@ -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' +} diff --git a/lib/hooks/use-detail-pager.ts b/lib/hooks/use-detail-pager.ts new file mode 100644 index 00000000..1af9ae04 --- /dev/null +++ b/lib/hooks/use-detail-pager.ts @@ -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(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, + } +} diff --git a/lib/navigation/__tests__/list-context.test.ts b/lib/navigation/__tests__/list-context.test.ts new file mode 100644 index 00000000..e000ba79 --- /dev/null +++ b/lib/navigation/__tests__/list-context.test.ts @@ -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 = {}) { + const map = new Map(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() + }) +}) diff --git a/lib/navigation/list-context.ts b/lib/navigation/list-context.ts new file mode 100644 index 00000000..93aeac6e --- /dev/null +++ b/lib/navigation/list-context.ts @@ -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 + +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:'. */ +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, + } +} diff --git a/messages/en.json b/messages/en.json index e99613d0..b481805a 100644 --- a/messages/en.json +++ b/messages/en.json @@ -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", diff --git a/messages/sv.json b/messages/sv.json index eb27e63c..c6526651 100644 --- a/messages/sv.json +++ b/messages/sv.json @@ -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",