Skip to content

Replace Tabulator's paginator in limel-table with limel-pagination #4303

Description

@Kiarokh

Technical findings from a review session with Claude Opus 5 against the current lime-elements source, the Tabulator build in node_modules, and the two CRM consumers that depend on the current paginator. Every reference below was verified against the code, and the line numbers are to the source as it stood before the change.

Why replace it at all

The visible reason is that limel-table is the last place in the library drawing a paginator that isn't ours. The reasons that survive a second look are not cosmetic.

It is the only way to make the control accessible. Page.js's _generatePageButton marks the current page with a CSS class and sets aria-label and title — there is no aria-current in it. A screen-reader user can operate the paginator without ever being told which page they are on. limel-pagination renders the control as a labelled <nav> and marks the current page with aria-current="page". This cannot be fixed with a stylesheet.

It closes a leak we cannot otherwise reach. Every page button Tabulator builds registers a translation binding through langBind, and Localize.bind only ever pushes into bindings; nothing is ever released. Up to five buttons are rebuilt on every run of the pagination pipeline, so a long-lived table — a remote one refreshing on a timer — retains detached buttons and their closures for as long as it lives, and a language change walks all of them. Suppressing the buttons with paginationButtonCount: 0 only becomes available once we are no longer relying on them being drawn.

It removes a full data reload from every count change. In remote mode the buttons are built from the last_page returned by ajaxRequestFunc, so the only way to refresh them is a replaceData() round trip that rebuilds every row, flickers, and loses vertical scroll — which refreshRemotePaginator then restores by hand. A paginator that reads a prop makes the whole method unnecessary.

It lets the table admit what it does not know. limel-pagination tells no count yet apart from empty, so a remote table holds the control's shape while a total is in flight instead of collapsing to a single page and springing back when it lands. Tabulator's paginator draws whatever max it currently holds and has no way to express the difference.

It makes pagination one thing to improve. The page-jump field for long page counts (#4296, #4306) reaches the table at the same moment as everywhere else, and a fix made in limel-pagination reaches every consumer of it at once rather than having to be made twice.

It reduces our coupling to a dependency we do not control. Around 148 lines of SCSS were matched against Tabulator's internal markup, with a further 34 working around where Tabulator chooses to put the element. Both are deleted. Selectors against a third party's DOM fail silently on upgrade; markup we own does not.

The cost is roughly 435 lines of pagination code, plus its tests, that we now maintain — and a bridge that keeps our control in step with Tabulator's page.

Motivation

limel-table renders Tabulator's own paginator. limel-pagination (#4296) is our paginator, and once it exists the table is the last place in the library with a second one — a different control, a different keyboard model, a different visual language, and about 140 lines of SCSS spent making a third-party widget look like ours.

Swapping it is also subtraction. Two workarounds exist only because we cannot reach inside Tabulator's paginator, and both fall away:

  • refreshRemotePaginator (table.tsx:727-765). In remote mode the paginator buttons are rendered from the last_page value returned by ajaxRequestFunc, not from setMaxPage — so when totalRows or pageSize change after init, the only way to refresh the buttons is to force a whole replaceData() round trip. That rebuilds every row, flickers, and loses vertical scroll, so the method saves and restores the scroll position by hand. A paginator that derives its page count from a prop needs none of this.
  • The paginationLocation: 'top' CSS (tabulator-custom-styles.scss:192-210). Tabulator puts the paginator in .tabulator-footer, which also holds the aggregates row, so moving the paginator to the top is done with order on the footer — which drags the aggregates up with it, which is then pushed back down with position: absolute; bottom: 0. Rendering the pagination as a sibling in the table's own JSX makes the location a matter of where the element goes.

Approach: keep Tabulator paging, replace only the control

Tabulator's pagination module stays on. It keeps slicing rows, holds the current page, derives the max page, and sends the remote paging params. Only its rendered buttons are suppressed:

  • Pass paginationElement: <a detached div> in getPaginationOptions() (table.tsx:880-891). Page.js:404-405 assigns it as the paginator's container, and Page.js:433 then skips footerAppend, so Tabulator's controls are built into a node that is never in the document.
  • Render <limel-pagination> in the table's JSX with pageSize and totalItems, and feed its page from Tabulator's current page rather than from the table's own page prop. limel-pagination is controlled: it shows exactly the page it is given and never moves itself, so the loop is click → setPage → Tabulator pages → pageLoaded → the table records the page → the control follows.
  • Wire onGoToPage to this.tabulator.setPage(event.detail.page).
  • handlePageLoaded (table.tsx:968) records the new page and keeps emitting the existing changePage, so the table's published event and every consumer of it are untouched. Note that it returns early in remote mode, so the record has to happen above that early return or remote tables never follow.

The alternative is to turn Tabulator's pagination off and slice data in the table. It is workable — limel-pagination deliberately slices nothing (pagination.tsx:26-29), the event already carries offset and limit, and remote-mode consumers already hand-slice exactly this way (examples/table-remote.tsx:115-118). It loses more than it saves, though: we would reimplement the local/remote split, the max-page derivation, the ajax paging params and the pageLoaded hook that changePage is built on, and every one of those is a chance to change behaviour a consumer depends on. Suppressing the view is a smaller, more reversible change than replacing the engine.

Note that pagination is already opt-in: getPaginationOptions() returns {} when there is no pageSize (table.tsx:881-883), and only four of roughly twenty examples set one. Most tables are unaffected either way.

What has to change alongside

1. totalRows needs to be nullable. limel-pagination reads totalItems: null as the count has not arrived yet and holds the control's shape while it is in flight; 0 means empty. totalRows is typed plain number (table.tsx:112-113), so a consumer mid-fetch can only say 0 or undefined. limec-system-health-center is in exactly that state — its totalRows getter returns 0 until providerCounts loads, while the page is already rendered. Widening to number | null and passing it straight through is additive for every existing consumer, and it makes the state expressible for the first time. limec-list-view already documents and dodges this trap with a nullable paginationTotal; the table currently cannot offer its consumers the same dodge. (Superseded — see As built below. The prop kept its type; the table expresses the unknown count itself.)

2. The two totals accessors disagree on zero. render() uses this.totalRows ?? this.data.length (table.tsx:1148), so an explicit 0 stays 0. calculatePageCount() uses if (!total) { total = this.data.length; } (table.tsx:1065-1071), so an explicit 0 becomes data.length. Whichever expression feeds totalItems decides what the control shows: with totalRows={0}, 25 rows of local data and pageSize 10, one says one page and the other says three. Both CRM call sites that can pass 0 are remote-mode or empty today, so this is latent rather than live — but the swap is what makes it matter. Resolve to a single private accessor used by calculatePageCount, the has-pagination class and totalItems alike, so the control and the rows can never describe different sets.

3. The single-page escape hatch needs re-pointing. --limel-table-single-page-paginator-display (partial-styles/tabulator-paginator.scss:9-13) is @private but real: limec-table-view uses it for Object Explorer widgets (table-view.scss:18) and the automations execution-order dialog uses it too (execution-order-dialog.scss:23). Its selector is #tabulator-container .tabulator-paginator, an element that stops existing. Nothing would fail to build or test — both surfaces would just silently regain a one-page paginator. Re-point the rule at the new element and keep the variable name, so both call sites keep working untouched. Worth knowing that limel-pagination always renders by design, so this hatch is the only way those two surfaces get the old behaviour.

4. goToPage must be stopped at the table's boundary. limel-pagination's event bubbles and composes, so one rendered inside the table's shadow root fires goToPage at the table's own consumers, retargeted to look like it came from <limel-table>. It is not in HTMLLimelTableElementEventMap, so it would be an event consumers can receive but cannot legitimately bind — invisible to the readme, untyped in addEventListener, unavailable as a JSX prop. Call event.stopPropagation() first thing in the handler, exactly as selectAllOnChange already does for the inner limel-checkbox (table.tsx:1017-1020) and as limec-list-view does for this very event. The table keeps changePage as its one page-change event.

5. setPage() rejects for an out-of-range page, and the existing call site does not catch. Page.js:531-539 returns a rejected promise after a console.warn when a local-mode page is outside 1..max, and pageChanged already calls this.tabulator.setPage(this.page) unawaited and uncaught (table.tsx:342). Driving setPage from user clicks widens the window: limel-pagination derives its page count from totalItems while Tabulator derives max from the data it holds, so a consumer setting totalRows larger than data.length in local mode turns a click into an unhandled rejection. Attach a .catch() to both call sites and keep updateMaxPage() (table.tsx:723-725) fed from the same number as totalItems.

Because the control is fed from Tabulator's page, a rejected setPage is at least not visible to the user: pageLoaded never fires, the recorded page never changes, and the control stays where it was rather than pointing at a page the table is not showing. The rejection still has to be caught.

What changes for users

First and last buttons go away as buttons. Tabulator renders dedicated first/prev/next/last controls (Page.js:427-431), and roughly seventy lines of tabulator-paginator.scss (76-147) exist to style the data-page='first'|'last' arrows. limel-pagination has none, deliberately: page 1 and the last page are always rendered as numbers, so both ends stay one click away and the targets say where they go rather than being decoded from an icon. The capability survives, the affordance changes, and the SCSS becomes dead. Worth a screenshot diff, since __screenshots__/ covers the table.

Nothing else is lost. Tabulator's paginationCounter and paginationSizeSelector both default to false (Page.js:38, 41) and the table never sets either, so there is no "showing X of Y" readout and no rows-per-page selector to replace. paginationButtonCount defaults to 5, which is exactly VISIBLE_PAGES in pagination.util.ts, so the window of page numbers stays the same size.

One behaviour improves. Tabulator's remote path handles a set that shrank under the user by logging Remote Pagination Error - Server returned last page value lower than the current page (Page.js:855). limel-pagination handles the same case by capping the page and emitting, so the user is moved to the last page that exists instead of being left on one that does not.

Suggested order

  1. Widen totalRows to number | null and resolve the two totals accessors into one. Independently useful, and it ships without touching the control.
  2. Swap the control behind paginationElement, wire onGoToPagesetPage, stop the event at the boundary, catch the setPage rejection.
  3. Re-point the single-page hatch, and verify both CRM surfaces in the same PR.
  4. Delete the dead paginator SCSS, refreshRemotePaginator and the paginationLocation order workaround — each in its own commit, since dead-code removal is the change most likely to prompt "why is this going?".

refreshRemotePaginator and the order workaround are expected to fall away rather than proven to; confirm both during step 2 rather than assuming it.

As built

The implementation departs from the plan above in four places, each found while building it.

totalRows kept its type. Widening it to number | null turned out to be unnecessary. A local table pages the rows in data and ignores a total altogether, and a remote table with rows on screen and no usable total already reports its count as unknown — so a consumer never has to say null to express the state. limec-system-health-center's 0-until-loaded getter lands on exactly that path and gets the held shape it wanted. A local total that disagrees with the rows is ignored and warned about once, naming both numbers, rather than being silently discarded.

Tabulator's page buttons are switched off, not merely hidden. paginationElement keeps them out of the document, but _setPageButtons goes on building up to five of them on every run of the pipeline, and each one registers a langBind callback that Localize.bind never releases. paginationButtonCount: 0 leaves the generating loop with a lower bound above its upper bound, so none are built at all.

A page-size change is passed on to Tabulator. paginationSize is read once at construction, so changing pageSize left Tabulator slicing by the old size while the control counted pages by the new one — with 25 rows moved from 10 to 20 per page, rows 21 to 25 could not be reached by any click. setPageSize re-slices in place; a full rebuild is kept only for turning pagination on or off, which cannot be done after construction.

The pagination lives in two files of its own. pagination.ts holds the counting rules and the two warning texts, with no Tabulator and no Stencil in them; table-pagination.ts holds the class that drives Tabulator and keeps the control in step. This follows the split selection.ts and table-selection.ts already use. table.tsx keeps the props, the state the control is drawn from, the watchers and the JSX.

Not in scope

Whether limel-table should keep Tabulator at all. This issue assumes it does.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions