67febd5097
* 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>
98 lines
3.3 KiB
TypeScript
98 lines
3.3 KiB
TypeScript
/**
|
|
* 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,
|
|
}
|
|
}
|