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:
co-authored by
Claude Fable 5
Jakob Wennberg
parent
bffa57a565
commit
67febd5097
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user