Skip to content

Pagination jump to page - #4306

Merged
Kiarokh merged 4 commits into
mainfrom
pagination-jump-to-page
Sep 19, 2026
Merged

Kiarokh merged 4 commits into
mainfrom
pagination-jump-to-page

Conversation

@LucyChyzhova

@LucyChyzhova LucyChyzhova commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Turns the ··· in limel-pagination into a button that opens a small form for going straight to a page. Closes #4298.

The control

Each gap becomes a button with an accessible name, showing the pressed look while its form is open so that one of two identical markers can be seen to be the one you opened. The form holds a number field, starting from the page you are on, and a Go button. Enter commits, Escape closes, clicking outside dismisses.

Both gaps open the same form, and it reaches any page in the set. Scoping each gap to the pages it happens to hide was considered and rejected: two markers that look identical would behave differently, with nothing on screen to say which is which, and someone who wanted page 3 would be turned away by the gap on the right for no reason they could see.

A page outside the set is not refused, it moves to the nearest page that exists. The set has a first and a last page, both written in the field, so someone typing past the end meant the end. Refusing would leave them to work out a bound the control already knows.

Asking, not moving

The component is controlled, so committing only emits goToPage — the control does not move until you set page. That shapes the focus behaviour, which is the part most worth reviewing:

On commit the form closes and the keyboard goes back to the marker it came out of, which is still on screen. If you decline the request — by not setting page, which is the documented way to decline — focus stays there and nothing has moved. When the page does arrive, focus follows to the button for that page, which carries aria-current="page", so a screen reader reads out that it arrived. If the user moved focus themselves while waiting, it is left alone.

A component of its own

The form is limel-pagination-jump, a private component, rather than markup inside limel-pagination. A popover carries its content into a shadow root elsewhere on the page, where the stylesheet of the component that opened it cannot reach — the alternative was laying the form out with inlined styles. A component brings its own stylesheet through the portal, and gets its own tests and example page. limel-pagination.tsx is about 70 lines smaller as a result.

It draws nothing until it is opened, so a pagination that is merely on screen pays for an empty element rather than a live text field per gap.

How it is tested

Three layers, each where it can actually be exercised:

  • What a typed value asks for is pageFromInput, a pure function in pagination.util.ts, table-tested for clamping, garbage, decimals and magnitudes past 2**53.
  • The form itself is tested in a browser, against the component on its own with no popover in the way. It is not a .spec.tsx because the mock DOM used for spec files cannot host Material's text field inside another component — it recurses into MDC's focus handling and overflows the stack. That was confirmed to be the environment and not the code, by disabling focus and by removing the value feedback: it still crashed.
  • The wiring — that the gap opens the form, that the page it reports becomes goToPage, and the focus behaviour above — is tested against limel-pagination in a browser, where the portal exists.

The full suite passes: 2493 tests across 140 files.

Shared fixes

This branch currently also carries the three limel-popover / limel-menu fixes that came out of building it. They are up for review on their own in #4307 and will drop out of here when this is rebased on top of it, so please review them there rather than in this PR.


🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added jump-to-page controls to pagination gaps, allowing users to enter and navigate directly to any page.
    • Added input validation, page-range clamping, loading-state handling, focus management, and localized labels.
    • Added Danish, Dutch, English, Finnish, French, German, Norwegian, and Swedish translations.
    • Added a basic pagination jump example.
  • Bug Fixes

    • Improved pagination popover behavior, accessibility states, focus restoration, and cleanup during navigation.

Review:

  • Commits are atomic
  • Commits have the correct type for the changes made
  • Commits with breaking changes are marked as such

Browsers tested:

(Check any that applies, it's ok to leave boxes unchecked if testing something didn't seem relevant.)

Windows:

  • Chrome
  • Edge
  • Firefox

Linux:

  • Chrome
  • Firefox

macOS:

  • Chrome
  • Firefox
  • Safari

Mobile:

  • Chrome on Android
  • iOS

@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

Note

Currently processing new changes in this PR. This may take a few minutes, please wait...

⚙️ Run configuration

Configuration used: Repository: Lundalogik/lime-elements/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 871f6cd2-facd-4a18-9c64-55bbf48fc972

📥 Commits

Reviewing files that changed from the base of the PR and between 666d7cb and c83cdfa.

⛔ Files ignored due to path filters (1)
  • etc/lime-elements.api.md is excluded by !etc/lime-elements.api.md
📒 Files selected for processing (20)
  • src/components/pagination/examples/pagination-single-page.tsx
  • src/components/pagination/jump/examples/pagination-jump-basic.tsx
  • src/components/pagination/jump/pagination-jump.e2e.tsx
  • src/components/pagination/jump/pagination-jump.scss
  • src/components/pagination/jump/pagination-jump.tsx
  • src/components/pagination/pagination.e2e.tsx
  • src/components/pagination/pagination.scss
  • src/components/pagination/pagination.spec.tsx
  • src/components/pagination/pagination.tsx
  • src/components/pagination/pagination.util.spec.ts
  • src/components/pagination/pagination.util.ts
  • src/translations/da.ts
  • src/translations/de.ts
  • src/translations/en.ts
  • src/translations/fi.ts
  • src/translations/fr.ts
  • src/translations/nl.ts
  • src/translations/no.ts
  • src/translations/sv.ts
  • vitest-setup.ts
 ___________________________________________________
< Great artists ship, but not before a code review. >
 ---------------------------------------------------
  \
   \   (\__/)
       (•ㅅ•)
       /   づ
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown

Documentation has been published to https://lundalogik.github.io/lime-elements/versions/PR-4306/

@Kiarokh

Kiarokh commented Sep 19, 2026

Copy link
Copy Markdown
Contributor

🤖 AI-generated review from 7 parallel agents at commit 0bd018e83. Treat as input, not a verdict — agents can be wrong or miss context. Nothing in the PR has been changed by this review.

🔄 Updated review. The previous review was at ae98507b1. ✅ Fixed = resolved since then; 🆕 New = flagged for the first time.

The branch has since moved to 5c04b6a1f. The agents' findings are unchanged below; what has been done about each is marked, and those marks — unlike the findings — are the author's, not a second agent pass. Each is backed by a test that was confirmed to fail without the code it covers, or by a command whose output is quoted.

Consolidated PR Review

PR Summary

Review tier: Full — 1271 changed lines across 21 files after noise filtering (nothing was filtered out; no security-sensitive paths). Seven agents ran: Backward Compatibility, Code Quality, Observability and Performance on opus, Architecture and Security on fable, plus a verification reviewer on opus that checked every item from the previous review.

The PR turns the ··· gap marker in limel-pagination into a button that opens a small form for going straight to any page in the set. The form is a new private component, limel-pagination-jump, so its stylesheet travels with it through the popover's portal; typed values are clamped to the pages that exist, committing only emits goToPage because the component stays controlled, and focus goes back to the gap and then follows to the new current page once the consumer shows it. 6 commits, 21 files, +1233/−38 lines after noise filtering.

Since the previous review the branch has answered all four of that review's Top Recommendations and five of its nine nits. Of the four raised this round — one High and three Mediums, all in new code — three are fixed and one is deferred to #4308, because its fix is a new method on limel-input-field, a shared component this PR does not touch. Three nits are left deliberately: they are limel-popover behaviour shared by every consumer of it, so a change belongs in that component.

Merge Readiness — READY TO MERGE ✅ (was BLOCKED 🛑)

The blocker is gone, and not by regenerating the API report. Top Recommendations 1 and 2 turned out to be one question — whether pageCount should accept null at all — and narrowing it back to number answered both: the committed report was already right, so api:verify now leaves it untouched, and the half-built null branch that rendered 1–0 is gone rather than finished. A jump field with no upper bound cannot say which set it is jumping within, cannot clamp and has nothing to put in its placeholder; it is a different control, not this one missing a number. The invariant that makes the narrowing safe — a gap cannot exist before the count does — is now stated in renderGap rather than left implied by a type.

One Medium is deferred rather than fixed: reading the typed value out of limel-input-field's shadow root (Top Recommendation 3). Its fix is a new method on that component, so it is tracked in #4308 and belongs in its own PR, the way the limel-popover fixes from this branch did.


Dimensions

Short summaries. Each issue points to a numbered Top Recommendation below.

1. Backward Compatibility — GOOD ✅ (was PROBLEM ❌)

Nothing a consumer of limel-pagination relies on changes, and the committed API report matches the source again.

Issues:

  • ✅ Fixed [High] The API report recorded pageCount as number while the source said number | null (etc/lime-elements.api.md:717, 3136, 3150), so npm run api:verify failed the Build job. See Top Recommendation 1 below.

What works well: Same props, same goToPage event and payload, and the narrowed PageSlot type was never part of the published API; the new element is marked @private/@internal, so no new public API is committed to, and the only external consumer found, lime-crm-components' list-view.tsx:349, needs no change; all eight translation files carry the four new keys, so no language falls back to a missing string; the internal jump event is stopped at the pagination so consumers never see it, and moving the loading guard into goTo leaves page buttons and arrows behaving exactly as before.

Minor nits:

  • 🔵 While a gap's form is open, the first click anywhere else on the control — a page number, an arrow, or the other ··· — only closes the form (src/components/pagination/pagination.tsx:461-490, renderGap, with src/components/popover/popover.tsx:178-186). Still present, left deliberately: it is limel-popover behaviour shared by every consumer of it, so a change belongs in that component.
  • ✅ Fixed: The component's own documentation still describes the gap as a passive marker — src/components/pagination/examples/pagination-single-page.tsx:24-25 and the class doc at src/components/pagination/pagination.tsx:51-53 now say the ··· is a button that opens a field.

2. Code Quality — NEEDS ATTENTION ⚠️

The three imperfections from last time are fixed; what remains is a test gap on the path the PR is most exposed on, and a null branch that renders wrongly.

Issues:

  • ✅ Fixed [Medium] Nothing tested a real commit reaching the pagination. Every wiring test synthesized new CustomEvent('jump') on the jump element. See Top Recommendation 4 below.
  • ✅ Fixed [Medium] The placeholder read 1–0 when the count was null, because Intl.NumberFormat formats null as "0". The count no longer accepts null. Same issue as Architecture's; see Top Recommendation 2 below.
  • ✅ [Medium] Focus is lost when the gap that opened the form is replaced by a page number (src/components/pagination/pagination.tsx:578-589, ownsFocus). See Top Recommendation 5 below.
  • ✅ [Medium] The field shows the starting page again once it loses focus (src/components/pagination/jump/pagination-jump.tsx:75-77, value). See Top Recommendation 6 below.
  • ✅ [Medium] pageFromInput drops the lower bound when the page count is unknown (src/components/pagination/pagination.util.ts:42-49). See Top Recommendation 8 below.
  • ✅ [Medium] The stale-jump guard compared two values that are always equal (src/components/pagination/pagination.tsx:554-563, followJumpedPage). Flagged this round against 0bd018e83 and fixed in bddf9ce82. See Top Recommendation 9 below.

What works well: The pure part of the feature, pageFromInput, is separated out and table-tested for clamping, garbage, decimals and values past 2^53, now including below-1 values with a null count; tests sit where the behaviour can actually run — spec for the markup, browser for the portal — each with a short note saying why; the gap slot variant drops from/to, which nothing reads now that both gaps open the same unscoped form; and goTo is the single place a move is refused while loading.

Minor nits:

  • 🔵 The first click on the other ··· while one form is open only closes the open one (src/components/pagination/pagination.tsx:461-490) — still present, left deliberately; the same popover behaviour as the nit under Backward Compatibility.
  • ✅ Fixed: The doc comment "Opening starts the field again rather than leaving what was last typed" sat above handleLanguageChange but described handleOpenChange. It now sits on the watcher it describes.
  • ✅ Fixed: closeJumpWithoutAGap was untested. The browser test "puts the field away when its gap stops being one" now covers it, and goes on to set a page that puts a gap back in that slot — the missing guard is only visible there, as the form springing open on its own. Confirmed to fail with the guard removed.
  • ✅ Fixed: The comment at src/components/pagination/pagination.e2e.tsx:7 says the field's own behaviour is covered in jump/*.spec.tsx — it now points at jump/pagination-jump.e2e.tsx.
  • ✅ Fixed: .gap in src/components/pagination/pagination.scss:75-84 uses !important with nothing saying why — lines 79-88 now keep main's --contrast-1100 comment and add one saying the !important beats the more specific hover and active shadows.

3. Architecture — NEEDS ATTENTION ⚠️

The shape is right and the focus hand-off is now complete, but the jump form declares a null count it does not fully handle and depends on limel-input-field's internal markup.

Issues:

  • ✅ Fixed [Medium] pageCount: null was declared but only half handled. It is narrowed to number, with the invariant stated in renderGap. Same issue as Code Quality's; see Top Recommendation 2 below.
  • 🔷 Deferred [Medium] The form reaches into limel-input-field's shadow root for the live value and for select() (src/components/pagination/jump/pagination-jump.tsx, target / nativeField). Tracked in Input field: no supported way to read what is typed, or to select it #4308; the fix is a new method on limel-input-field. See Top Recommendation 3 below.
  • ✅ [Medium] Focus follow is dropped when the opened gap becomes a page (src/components/pagination/pagination.tsx:544-589, followJumpedPage / ownsFocus). Same issue as Code Quality's; see Top Recommendation 5 below.

What works well: slots is settled once in componentWillRender and only read by render, so closeJumpWithoutAGap inspects the same window the render will show; focus responsibility is cleanly split — limel-popover restores focus for its own dismissals, pagination handles the commit, and every user move goes through goTo, where awaitedJump is reset; the form is a private component with a narrow surface that carries its own stylesheet through the portal, pageFromInput is a pure function, and PageSlot is not part of the public surface.

Minor nits:

  • 🆕 New: The pressed look keys off aria-expanded (src/components/pagination/pagination.scss:86-88), an attribute pagination never renders — limel-popover writes it on its trigger in its own componentDidRender (src/components/popover/popover.tsx:116-122, 230-234) — so a change to how popover decorates its trigger would silently take the pressed look with it, where a class rendered from openGap would not.
  • 🆕 New: settledLanguage and numberFormat describe one fact in two fields (src/components/pagination/pagination.tsx:143, 359-362, 927-931): handleLanguageChange clears numberFormat but leaves settledLanguage at the previous language, so it is only correct when read through languageForNumbers or after formatNumber, both of which rebuild the formatter first.
  • ✅ Fixed: awaitedJump is only cleared when the asked page arrives or focus moves — still present at 0bd018e83, where the guard added for it compared two always-equal values, and fixed in bddf9ce82, which remembers the page the jump was asked from and gives the jump up when anything else is shown (Top Recommendation 9).

4. Security — GOOD ✅

No findings: the typed page is parsed, bounded and clamped before anything is emitted, and nothing new reaches a raw-HTML sink.

What works well: The typed page is the only new input, and it goes through pageFromInput (parseInt, Number.isSafeInteger, clamp to 1..pageCount) before commit emits it, so a jump can only produce a goToPage payload that clicking a page button could already produce, and the uncapped null-count path is unreachable through the public component because slotsFor renders no gap without a count; every new string reaches the screen as Stencil text or an attribute, translations are interpolated with String(...) and no HTML sink, and the [id="…"] selector in focusGap is built only from the component's own random prefix and a numeric slot index; the private jump and the field's change events are stopped where they are handled, so nothing new bubbles out of the portal container on document.body, and the PR adds no dependencies.

Issues: None.

5. Observability — GOOD ✅

The one console message about a bad language now describes what both halves of the control did.

What works well: languageForNumbers (src/components/pagination/pagination.tsx:927-931) hands the jump form the language the parent settled on, so the single console.warn describes what both halves actually did, and its new read during renderGap goes through the same memoised formatter, so the warn-once map still emits each message exactly once per bad value; the gap marker carries aria-disabled="true" while a page is loading, so a refused press is reported rather than silently inert; a jump the consumer honours is announced by the existing live region, and clamped corrections still reach the consumer through reportCorrection, untouched by the new jump path.

Issues:

  • ✅ [Medium] The warning says "Using en" while the jump form formats in another locale (src/components/pagination/pagination.tsx:485, language={this.languageForNumbers}). See Top Recommendation 7 below.

Minor nits:

  • 🔵 handleJump (src/components/pagination/pagination.tsx:513-524) closes the form whether or not goTo accepted the page, and goTo (:723-733) drops a request for the page already shown, or one made while loading, with no event and no live-region message, so typing 9999 on the last page, or the page you are on, looks exactly like a jump that worked; Go on an empty field (src/components/pagination/jump/pagination-jump.tsx:167-175) is silent the same way, though there the form at least stays open. Still present.
  • 🆕 New: The jump form's own createNumberFormat (src/components/pagination/jump/pagination-jump.tsx:244-250) falls back to English without any console message where its parent warns once with the prop name, the value and the fallback; through limel-pagination this never fires, because the parent passes the language it settled on, but the example page and pagination-jump.e2e.tsx render limel-pagination-jump directly, and there an unusable tag silently produces English numbers with nothing in the console to explain it.

6. Performance — GOOD ✅

Nothing on a hot path: the field is built only when a gap is opened, and the slot maths stays constant in the page count.

What works well: render returns nothing while the form is closed (src/components/pagination/jump/pagination-jump.tsx:117-119), so a pagination merely on screen never instantiates limel-input-field or its Material text field; collectVisiblePages never builds more than seven numbers whether the set has 8 pages or 492, and pageFromInput is one parseInt plus a clamp; Intl.NumberFormat is now built once per language in the jump form as well as in the parent (:232-234 with the language watcher at :93-96), and typing only re-renders on the field's own debounce with no write-back loop, because limel-input-field's valueWatcher skips an equal focused number.

Issues: None.

Minor nits:

  • 🔵 Each closed gap renders a limel-popover, and with it a limel-portal and a limel-popover-surface, around an empty limel-pagination-jump (src/components/pagination/pagination.tsx:461-490, renderGap) — about eight extra custom elements for two gaps, paid whether or not anyone opens the form. Still present, left deliberately: it is how limel-popover works for every consumer, so a change belongs in that component.
  • ✅ Fixed: formatNumber in src/components/pagination/jump/pagination-jump.tsx builds a new Intl.NumberFormat per call — lines 232-236 now cache it with ??= and a @Watch('language') clears the cache (:93-96), matching the parent.

Top Recommendations

These are suggestions for the author. Nothing has been applied.

  1. ✅ Fixed — Bring the API report back in line with pageCount so api:verify passes
    Answered by narrowing the prop rather than regenerating the report, which is what Top Recommendation 2 concluded. The committed report already said number, so a fresh stencil build followed by npm run api:verify now leaves etc/lime-elements.api.md untouched; the errors that remain in that command are the same ones a clean main produces, and none of them mentions pagination.

    See what was originally recommended

    Introduced by this PR · small fix · belongs in this PR.

    • Where: etc/lime-elements.api.md:717, 3136, 3150 (LimelPaginationJump.pageCount) vs src/components/pagination/jump/pagination-jump.tsx:55 (pageCount)
    • What: Run npm run api:update and commit the regenerated etc/lime-elements.api.md; the three lines will come out as number | null, the way the sibling totalItems is recorded at lines 709, 3112 and 3126. If Top Recommendation 2 is answered by narrowing pageCount back to number, the committed report is already right and needs no regeneration.
    • Why: The last commit widened the prop to number | null but the report, last written in ab624ca6b, was not regenerated, and the Build job in .github/workflows/pr-checks.yml runs npm run api:verifyapi-extractor run without --local — which fails whenever the generated report differs from the committed one, so the PR fails that check as it stands and would leave main failing it too.
  2. ✅ Fixed — Decide whether pageCount really accepts null, and handle it fully if it does
    Decided: it does not. The prop is number again. A jump field with no upper bound cannot name the set it is jumping within, cannot clamp and has nothing to put in its placeholder — it is a different control, not this one missing a number, so finishing the branch would have meant inventing display rules for a state nothing can produce. The component being @beta and unused was taken as a reason to land the shape we want, not to keep the one already written. pageFromInput still accepts null, because there the case is real and tested. The bridge is an early return in renderGap saying why a gap cannot exist before the count does, rather than a cast.

    See what was originally recommended

    Introduced by this PR · small fix · belongs in this PR.

    • Where: src/components/pagination/jump/pagination-jump.tsx:50-55 (pageCount), :131 (max in render), :191-196 (pageRange)
    • What: Either make the contract match the one caller — pageCount: number, with the gap branch of renderSlot passing the count a gap guarantees exists — or, if null is meant to stay, write the placeholder without a last page when there is none and leave max unset.
    • Why: The prop was widened to number | null in the last commit and pageFromInput copes with null, but pageRange still formats it through Intl.NumberFormat, which turns null into "0", so the placeholder reads 1–0 and max={null} is handed to the field; limel-pagination never renders a gap without a count (slotsFor, src/components/pagination/pagination.tsx:408-414), so the branch serves no caller that exists, and the one way to reach it — using the private component directly — tells the user the set ends at page 0.
  3. 🔷 Deferred to Input field: no supported way to read what is typed, or to select it #4308 — Give limel-input-field a supported way to read what is typed, instead of reaching into its shadow root
    Agreed, and not done here: the fix is a new method on limel-input-field, a shared component this PR does not touch, so it wants its own review — the same call that was made for the limel-popover fixes this branch turned up.

    Checking the finding while writing it up narrowed it. Of the three needs, only two are genuinely unmet: reading what is typed, and selecting it. Focus is already supportedlimel-input-field is declared shadow: { delegatesFocus: true }, so focus() on the host reaches the inner input, which means editor-link-menu.tsx:84 reaches into the shadow root for no reason and needs no new API at all. And date-picker.tsx:291 and :346 are a different problem, not a smaller one: they hand the native element to flatpickr and read .mdc-text-field by its Material class name, neither of which a value API answers. Input field: no supported way to read what is typed, or to select it #4308 proposes getValue() and selectValue(), and says why a read beats flushing the debounce on Enter.

    See what was originally recommended

    Introduced by this PR · medium fix · belongs in this PR.

    • Where: src/components/pagination/jump/pagination-jump.tsx:184-186 (target), :203-205 (nativeField), :212-223 (focusField)
    • What: Add to limel-input-field the hook all three callers need — for example flush the debounced change on Enter keydown so this.value is current by the time the Host handler runs, or a @Method() getValue() and select() — and have the form use that instead of shadowRoot.querySelector('input').
    • Why: limel-input-field offers no synchronous way to read what is typed — its change is debounced 300 ms (src/components/input-field/input-field.tsx:38, 1017-1020) and its only @Methods are the three selection getters (:309-352) — so this becomes the third component depending on its internal markup, after date-picker.tsx:291 and editor-link-menu.tsx:84; if that markup changes, the ?? this.value fall-back silently submits the debounced, possibly stale, page while the select-all quietly stops working.
  4. ✅ Fixed — Test one real commit from inside the popover
    The browser test "carries a page typed in the popover out as a page to go to" now drives the field itself — setting the native input inside the portalled limel-pagination-jump and clicking its button — and asserts goToPage carried that page, rather than synthesising a jump event.

    See what was originally recommended

    Introduced by this PR · small fix · belongs in this PR.

    • Where: src/components/pagination/pagination.e2e.tsx:57-63 (jumpField, askFor)
    • What: In one pagination.e2e.tsx test, reach the limel-pagination-jump inside the popover surface, set the native input's value and click its limel-button, then assert goToPage carried that page.
    • Why: Every wiring test synthesizes new CustomEvent('jump') on the jump element, and jump/pagination-jump.e2e.tsx only exercises the component outside a portal, so the seam the PR is most exposed on — an onJump listener on an element limel-popover has moved into another shadow root — is the one thing no test covers, and the limel-popover fixes that came out of building this branch show that seam does break.
  5. ✅ Fixed — Keep following focus when the gap that opened the form is removed
    Verified: ownsFocus (src/components/pagination/pagination.tsx:578-589) now also returns true when !this.jumpFocus?.isConnected && document.activeElement === document.body, and the suggested browser test exists at src/components/pagination/pagination.e2e.tsx:151-167.

    See what was originally recommended

    Introduced by this PR · small fix · belongs in this PR.

    • Where: src/components/pagination/pagination.tsx:560-564 (ownsFocus), with src/components/pagination/pagination.tsx:536-553 (followJumpedPage)
    • What: Count the marker having left the DOM as still ours — inside === this.jumpFocus || (!this.jumpFocus?.isConnected && document.activeElement === document.body) — so focusCurrentPage runs when the component itself removed the gap. Extend the "other side of the gap" browser test to open gaps(root)[0], ask for 3, set root.page = 3 and assert focus lands on [aria-current="page"]; both existing focus tests jump 50 → 300, where the gap survives.
    • Why: On page 50 of 492 the slots are 1 ··· 49 50 51 ··· 492; jumping from the left gap to page 3 renders 1 2 3 4 5 ··· 492, so the popover at that slot is replaced by a page button, focus falls to body, and the !document.activeElement fallback written for this case never fires — the keyboard is left at the top of the document instead of on the new current page, which is the "stranded after a re-render" case the linked issue calls out.
  6. ✅ Fixed — Show what was typed once the field loses focus
    Verified: value is now @State() (src/components/pagination/jump/pagination-jump.tsx:75-77), updated from the field's change event in handleChange (:153-156) and passed as value in render (:128), while target still reads the native input (:184-188); startingValue is gone.

    See what was originally recommended

    Introduced by this PR · medium fix · belongs in this PR.

    • Where: src/components/pagination/jump/pagination-jump.tsx:116-124 (render), with src/components/pagination/jump/pagination-jump.tsx:76 and :96 (startingValue)
    • What: Keep the typed value in state — updating it from the field's debounced change event is enough for display — and pass that as value, while leaving target reading the native input so a fast Enter is still not stale.
    • Why: limel-input-field covers an unfocused number input with a span rendered from its value prop and makes the real input transparent, so tabbing from the field to Go — the keyboard path this control is built for — makes the field visibly revert to the page the user started on while Go submits what they typed.
  7. ✅ Fixed — Make the jump form resolve language the way its parent does
    Verified: the parent records the locale it settled on (settledLanguage, src/components/pagination/pagination.tsx:143, set in createNumberFormat :946-976) and hands the child that value via language={this.languageForNumbers} (:485, getter at :927-931), so zz reaches the jump form as en.

    See what was originally recommended

    Introduced by this PR · small fix · belongs in this PR.

    • Where: src/components/pagination/jump/pagination-jump.tsx:210-216 (formatNumber), fed the raw prop at src/components/pagination/pagination.tsx:478
    • What: Give the child the same resolved-locale check the parent already has in createNumberFormat, or have the parent pass down the language it settled on instead of the raw language prop.
    • Why: For a well-formed but unsupported tag such as zz, the parent warns "Using en" and writes 1,234 while the child's placeholder comes out in the machine's own grouping, 1–1.234; the single console message misdescribes what the control did, and the mismatch only shows on machines whose default locale differs from the developer's, where nobody is reading the console.
  8. ✅ Fixed — Clamp the lower bound even when the page count is unknown
    Verified: pageFromInput clamps the bounds separately (src/components/pagination/pagination.util.ts:42-49), the jump component's pageCount is widened to number | null (src/components/pagination/jump/pagination-jump.tsx:54-55), and the table test covers below-1 values with a null count (src/components/pagination/pagination.util.spec.ts:191). The widening is what Top Recommendations 1 and 2 follow from.

    See what was originally recommended

    Introduced by this PR · small fix · belongs in this PR.

    • Where: src/components/pagination/pagination.util.ts:32-43 (pageFromInput), with src/components/pagination/jump/pagination-jump.tsx:54 and src/components/pagination/pagination.tsx:476
    • What: Clamp the two bounds separately — const atLeastFirst = Math.max(typed, FIRST_PAGE); return pageCount === null ? atLeastFirst : Math.min(atLeastFirst, pageCount); — and either widen the jump component's pageCount to number | null or stop passing it the nullable this.view.pageCount. Add a table case for a value below 1 with a null count; the existing null-count case only covers '300'.
    • Why: Math.min(Math.max(typed, FIRST_PAGE), pageCount ?? typed) returns 0 for '0' and -3 for '-3' when the count is null, against the doc comment's promise of "a page within the set"; the only thing keeping it unreachable is that slotsFor renders no gap without a count, a contract the helper does not state and the number versus number | null typing hides.
  9. ✅ Fixed — Stale-jump guard compared two values that were always equal (flagged at 0bd018e83, fixed by bddf9ce82)
    At 0bd018e83 this was a new Medium from Code Quality, and the reason the previous Architecture nit about awaitedJump was still present. bddf9ce82 records jumpedFrom = this.view.page in handleJump, has followJumpedPage give the jump up when the page shown is neither the asked-for page nor jumpedFrom, and adds the browser test "stops waiting when the consumer answers with a different page", which the author confirmed fails against the old code.

    See what was found at 0bd018e

    Introduced by this PR · small fix · belongs in this PR.

    • Where: src/components/pagination/pagination.tsx:554-563 (followJumpedPage)
    • What: Do the comparison in componentWillRender before this.shownPage = page, where shownPage still holds the previous render's page, or record the page the jump started from in handleJump and compare against that.
    • Why: followJumpedPage runs from componentDidRender, but componentWillRender has already set this.shownPage to the same value it put in this.view.page (line 212), so this.view.page !== this.shownPage is always false and the branch that abandons a jump never runs; awaitedJump is then never given up when the consumer shows some other page, and if the control later arrives at the asked-for page on its own, focus is pulled from the gap to that page button with the user having done nothing — the exact case the comment above the branch says must not be followed.

Verified at 5c04b6a1f: npx stencil-test passes with 2513 tests across 140 files, eslint is clean, and api:verify leaves the committed report untouched. Every fix above that changes behaviour has a test that was confirmed to fail without it — including two that first passed for the wrong reason, one because the browser specs had no teardown and carried a popover container between tests, and one because it moved focus away and so never reached the guard it was meant to exercise. Both were rewritten until they failed against the old code.

Kiarokh and others added 4 commits September 19, 2026 17:24
A gap carried `from` and `to` so that a jump-to-page control could
offer the pages it hides. That control is now unscoped: both ellipses
open the same field and reach any page in the set, because two
identical markers that silently accept different ranges cannot be
told apart by the user. Nothing reads the range, so it goes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`limel-pagination-jump` is a number field and a button for going
straight to a page. It is what `limel-pagination` will put inside the
popover behind its `···`, and it is private, so nothing here is a
promise to anyone outside this repository.

It is a component rather than markup inside the pagination because a
popover carries whatever it holds into a shadow root elsewhere on the
page, where the stylesheet of the component that opened it cannot
reach. A component brings its own, and gets its own tests and example
page with it.

It draws nothing at all until it is opened, so a pagination merely on
screen pays for an empty element rather than a Material text field per
gap. It reads the page out of the field when asked to commit, rather
than from the field's `change` event, which arrives on a 300 ms
debounce: a page typed and submitted inside that window would otherwise
be submitted as whatever preceded it. What it shows still follows that
event, because the field draws an unfocused number from its `value`
prop, and a field pinned to the page it opened with would put the old
number back the moment the keyboard moved to the button.

A page outside the set is not refused. The set has a first and a last
page, both written in the field, and the nearer of them is what someone
typing past the end meant; `pageFromInput` is that rule, kept pure and
tested on its own. It takes a count of `null` because a set whose size
is unknown is a real state; the component does not, because a jump
field with no upper bound cannot name the set it is jumping within.

Its tests run in a browser rather than against the mock DOM, which
cannot host a Material text field inside another component. They drive
the component on its own, with no popover in the way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mock-doc does not implement the `slot` IDL property, so an element
written as `slot="trigger"` reads back `undefined`. A component that
routes its children by `element.slot` then behaves differently under
test than in a browser: `limel-popover-surface` keeps a trigger out of
its portal that way, and without the property it swallowed the trigger
instead, leaving nothing in the component to click.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A gap becomes a button that opens `limel-pagination-jump` in a popover,
so no page is more than one move away however long the set is. Enter
commits, Escape closes, and the marker shows a pressed look while its
field is open, so one of two identical markers can be seen to be the
one that was opened.

Both gaps open the same field, and it reaches any page in the set.
Scoping each one to the pages it happens to hide would make two
identical markers behave differently, with nothing on screen to say
which is which, and would turn away someone who wanted page 3 because
they opened the marker on the right.

The component is controlled, so committing only asks: it emits
`goToPage` and waits. That decides where the keyboard goes. On commit
the field closes and focus returns to the marker it came out of, which
is still on screen and is where it stays if the consumer declines to
show the page. When the page does arrive, focus follows to the button
for it, which carries `aria-current`, so a screen reader says where it
landed. If the user moved the keyboard themselves in the meantime, it
is left alone; and if the consumer answers by showing some other page,
the jump is given up, so a later unrelated arrival at the page once
asked for does not move the keyboard as though it were the reply.

Two states needed guarding. A gap is remembered by the slot it sits in,
and a page set from outside can put a page number in that slot, taking
the popover down without it ever closing — left open, it would spring
back the next time a gap landed there. And the marker holding the
keyboard can itself be taken away, which a browser answers by focusing
the body rather than nothing, so that is what the follow step looks for.

`goTo` becomes the one path every move the user asks for goes through,
and so the one place that refuses while a page is being fetched, rather
than each handler keeping its own check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Kiarokh
Kiarokh force-pushed the pagination-jump-to-page branch from 5c04b6a to c83cdfa Compare September 19, 2026 15:28
@Kiarokh
Kiarokh marked this pull request as ready for review September 19, 2026 19:53
@Kiarokh
Kiarokh requested a review from a team as a code owner September 19, 2026 19:53
@Kiarokh
Kiarokh merged commit 40f1929 into main Sep 19, 2026
17 of 18 checks passed
@Kiarokh
Kiarokh deleted the pagination-jump-to-page branch September 19, 2026 19:55
@Kiarokh Kiarokh self-assigned this Sep 19, 2026
@lime-opensource

Copy link
Copy Markdown
Collaborator

🎉 This PR is included in version 40.5.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pagination: jump to a page from the ellipsis

3 participants