Skip to content

[Plugin] Previous/Next paging and a page position on both admin lists - #241

Merged
vedanshujain merged 4 commits into
mainfrom
feat/prev-next-and-page-of
Aug 9, 2026
Merged

[Plugin] Previous/Next paging and a page position on both admin lists#241
vedanshujain merged 4 commits into
mainfrom
feat/prev-next-and-page-of

Conversation

@vedanshujain

Copy link
Copy Markdown
Contributor

Summary

Both admin lists gain a Previous/Next pager driven by a client-side cursor trail — Next
pushes, Previous re-requests the popped cursor, the walk rides history.state so browser
Back/Forward and a reload keep the position, and a page opened from a shared link degrades
honestly (dimmed Previous with its reason, position shown; the walk before it is unknown).

Page N of M derives from the same stated total the count line uses — an absent total renders
an em dash, the two lines share one gate and cannot disagree, an accumulated scan states its
window ("Pages 1–2 of 6"), and the last page prefers direct evidence over arithmetic.

The pager composes with Load more (one shared position), the low-stock degradation banner
(page-one answers are authoritative; continuations latch), the refusal recovery from the
cursor work, and the paging-stopped state.

Test plan

  • 486 unit/DOM tests across admin-react + admin-presentation (trail algebra including hostile
    history.state parsing, both latch directions, history-entry semantics, empty-state/count
    coordination) — all green
  • Plugin regression suite green
  • 129-assertion headless-browser pass covering Next/Previous walks, browser Back mid-walk,
    last-page links, filter resets, absent-total rendering, drill-in → reload → back
  • Changeset present

Both React lists gain a pager beside `Load more`. `Next` moves forward one page
and `Previous` moves back one, both exactly and both free: the console keeps the
cursors the service has already handed it and replays one to go back. No reverse
keyset read, no new query, nothing new on the wire. Both controls write the page
to the address through the path `Load more` already used, so Back and Forward
keep walking the pages an operator actually visited.

PREVIOUS RE-REQUESTS RATHER THAN RESTORING. The stack holds cursors, not pages.
A request under a token the service already issued answers with the collection
as it stands now, agrees with a reload of the very same address, and does not
grow without bound down a long scan; replaying rows kept in memory would do the
opposite of all three, and on Pricing & inventory the column an operator came
back to check is stock.

THE TWO WAYS FORWARD ARE TWO ACTS. `Previous`/`Next` move a one-page window;
`Load more` extends one and keeps the rows above. Both advance the same
position, so the page number counts either — what differs is whether the rows
stay. A pager step therefore continues from where an accumulated scan reached
and collapses the view to that page, which is the stated cost of offering both.
The intent rides on the cursor rather than beside it, so a REPLACE cursor can
never be read as an EXTEND one by an effect that saw two state writes in the
wrong order.

M IS DERIVED, NEVER FETCHED. The service already counts the filtered set beside
the page it returns and the plugin already states the limit it pages by, so the
page count is arithmetic over two values the render is holding. It is refused on
exactly the totals the count line refuses — one shared gate — so `Page 2 of 6`
and `137 orders` cannot contradict each other, and an absent total leaves an em
dash rather than a guess: absent is not one and it is not zero. A render whose
response carried no next cursor states its own page as the count, which is
direct evidence rather than arithmetic and the only answer available with no
total at all.

THE STACK RESETS WITH THE PREDICATE. A cursor is only meaningful against the
filter it was issued under, so a stack that survived an apply would offer to
step back into the set the operator just left.

A DEEP LINK CANNOT KNOW ITS PAGE. An address names which page, never how many
came before it, so the position reads `Page — of 6` and `Previous` is offered
dimmed with the reason: this screen has no record of the page before this one.
Paging forward from such a link still comes back to it. Inventing "page 2"
because one cursor was seeded would be a number an operator reconciles against
and loses.

Where paging has stopped — a failed page, or a continuation refused mid-scan —
the whole pager is withdrawn with `Load more`. The rows stay exactly where they
are; the paging goes, because the screen has just disowned its position in the
address. The mid-scan refusal semantics are otherwise untouched.

The controls dim with `aria-disabled` rather than `disabled`, so they keep their
tab stop and their focus ring: pressing `Next` onto the last page would
otherwise take the control out from under the operator's focus. Both go
unavailable on the click rather than on the effect, so the commit in between
cannot take a second press and push the same cursor twice.

Pinned by unit tests over the stack and the pager view, by presentation tests
over the page count and its coordination with the count line, and by a DOM suite
that walks two real screens: Next twice, Previous, the address after each, a
deep link, a filter apply, an absent total, an accumulated scan, and a refusal.
… honest

Review follow-ups on the Previous/Next work, all of them cases where the pager
stated something it could not back.

THE STACK SURVIVES THE BROWSER'S OWN BACK. The address carries one cursor, so a
traversal landed on a page with nothing behind it: the position fell to a dash
and `Previous` dimmed, two presses into a scan, for no reason visible on screen.
A history ENTRY is not a link, and can hold what a link must not — so the walk
rides in `history.state`, is read back on `popstate`, and seeds the remounted
list. Back mid-walk and Back-then-Forward both keep the position and a live
`Previous`.

PUSH AND CORRECT ARE NOW SAID, NOT INFERRED. "No cursor" meant page one, and
page one is reached both by the recovery from a page that would not open and by
an operator pressing `Previous` from page two — so every deliberate step back
was overwriting the entry it stepped from. The list states the intent; the
screen decides the entry.

A FAILED PAGE NEVER CLEARS THE ROWS. The old test was "did a cursor go out",
which is false for `Previous` onto page one, so a failure there wiped a
screenful of rows that were still a true answer. It is now "did the operator ask
for this page", and page one asked for is a request like any other rather than
the absence of one.

A PAGER STEP KEEPS THE LOW-STOCK LATCH. Every request carrying a cursor reports
the filter as available by contract — the predicate rode inside the token — so
inheriting that only on `Load more` meant `Next` dropped the banner a settings
blip had raised and began captioning the whole catalogue as low stock. The latch
rides on the continuation, not on the merge, and the withheld total goes with it.

CLAIMS TRIMMED TO WHAT IS KNOWN. The page count now consumes the figure the
count line actually stated rather than the raw payload one, so a total a scope
withheld cannot reappear beneath it. A last page whose arithmetic insists there
are more pages states neither figure instead of picking one. A window of several
pages states the range it describes, because "Page 3" over fifty rows beginning
at page two tells whoever is reading the top of that list the wrong number. And
`Next` says what paging on costs before the click rather than after it. The
dimmed `Previous` states ignorance — the page before this one is not known here
— instead of asserting a provenance a reload, a bookmark and a link all reach
identically.

A LINK TO THE LAST PAGE KEEPS ITS PAGER. Neither control can move there, which
is exactly when the position is the only thing that can answer the question that
operator arrived with.

THE CONTROL MOVED TO THE SHARED FILE and gained the accessibility it was
claiming. Its reason is an accessible description rather than only a tooltip
(the earlier note had that backwards — a tooltip is a pointer affordance); the
dimming is carried by the border so the label stays legible; and the unavailable
cursor is a sheet rule, since an ARIA state cannot be matched by `:disabled`.

Two invariants pinned rather than guarded: the same cursor pushed twice is one
page, so a batched double press cannot deepen the position past the walk; and a
pop with nowhere to go is inert, so a stack and a cursor that ever disagreed
cannot invent a page. The refusal copy names no direction — three controls
produce it now, and only one of them is "more".
…very entry

Round-two review follow-ups. Three of them were the pager making a claim from
the wrong evidence.

A REQUEST THAT CARRIED NO CURSOR IS PAGE ONE, whoever asked for it. The
classifier read the OPERATOR's intent instead, so `Previous` off the bottom of
the stack was treated as a page move — and that was wrong in two directions at
once. It captioned a render that IS the first page as not-the-first, which takes
the whole-collection empty copy away and puts the "on this page" hedge on a count
the render could prove; and on Pricing & inventory it carried the low-stock latch
over a response entitled to clear it, so a banner raised by a settings blip could
never be dismissed, while a blip happening ON that request could not raise one —
reproducing the mislabel where an unfiltered catalogue is captioned as low stock
with a whole-catalogue count behind it. The failure classification stays on "did
the operator ask for this page", and keeping the two questions apart is what
makes answering the first off the wire safe.

EVERY HISTORY WRITER CARRIES THE WALK, through one helper rather than five object
literals. The record drill-in pushes an entry that is a list entry wearing a
record's address, and it was composing that entry from the record id alone —
dropping the page one click after it was earned, so `Back to orders` from a
reloaded record landed on a list that could not name its own page. The two
replacing writers now merge into the entry rather than clobbering it, so a
writer that only means "no record is open" cannot take the stack with it, and
whatever else shares the entry survives all five.

THE DIM WAS OVERCORRECTED. The first cut took the label down with the border; the
second made unavailable and live nearly indistinguishable, which is worse — a
control that looks pressable and does nothing. The state is now carried by a flat
fill, a visibly lighter border, and a label muted through the theme's own
foreground: unmistakably off at a glance, and still a word rather than work.

Also: the deep-link latch exception is stated as a known limit rather than left
implied; the two failure-title constants are renamed to match the
direction-neutral copy they already carry; the partial-card comments say "paging
bar" rather than "where Load more was"; the span clamp says why it clamps; and
the forward step documents why it reads the stack out of its closure and what
the busy guard is still load-bearing for.

Newly pinned: both latch directions on the page-one path, the two `firstPage`
consequences, the drill-in-reload-back walk, Back/Forward around a record, the
entry-state merge, hostile and malformed `history.state`, and the last-page link
in the DOM tier. Each was checked by mutation — reverted, watched fail, restored.
…tually is

Two review follow-ups.

THE IDEMPOTENCE CLAIM WAS UNPINNED. `pushedPage` refuses a cursor equal to the
top of the stack, and the note beside it argues that this is an invariant rather
than a guard — but no test held it: none of the call sites pushes the current top
twice, so every existing test passed with the check deleted. It has one now, and
the neighbouring "a pop with nowhere to go is inert" claim has one too. Both were
checked by deleting the line and watching them fail.

AND THE MUTED LABEL'S RATIONALE WAS WRONG. `color-mix(…, transparent)` IS an
alpha; the comment claimed it was not, which is dressing up the same technique in
better words. What it actually buys is WHERE the alpha lands: an `opacity` fades
the whole control, so the fill and the border that carry the unavailable state
fade at exactly the rate the word on it does and the two jobs compete. Mixing
only the label leaves both at full strength. Stated against `currentColor` so it
follows the theme rather than pinning a grey that is legible in one of them.
@vedanshujain

Copy link
Copy Markdown
Contributor Author

Evidence

Products list, page one — badges intact, position reads Page 1 of 6.
products page one desktop

Products list, page one, narrow viewport — same position, same controls.
products page one narrow

Orders list opened from a link to the last page — both controls dimmed and explained, position shown as an em dash of 6.
orders last-page link desktop

Orders list, last-page link, narrow viewport — the dimmed pair still renders.
orders last-page link narrow

Products list after Load more — the pager reads the accumulated scan's window, Pages 1–2 of 6.
products with-scan desktop

Products list, accumulated scan, narrow viewport.
products with-scan narrow

Orders list with an absent total — Page 1 of an em dash, never "of 1" or "of 0".
orders no-total desktop

Orders list, absent total, narrow viewport.
orders no-total narrow

Products list after a browser Back mid-walk — Page 3 of 6 restored from history state.
products after-back desktop

Orders list after a browser Back mid-walk — same restoration, own history entry.
orders after-back desktop

Products list mid-scan hit by a stock-read blip — the total withholds itself, Page 2 of an em dash.
products blip desktop

Products list, page one, after that blip — low-stock badges degrade to plain counts and the total stays withheld.
products banner-raised desktop

@vedanshujain
vedanshujain merged commit f4cbc58 into main Aug 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant