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
- Widen
totalRows to number | null and resolve the two totals accessors into one. Independently useful, and it ships without touching the control.
- Swap the control behind
paginationElement, wire onGoToPage → setPage, stop the event at the boundary, catch the setPage rejection.
- Re-point the single-page hatch, and verify both CRM surfaces in the same PR.
- 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.
Technical findings from a review session with Claude Opus 5 against the current
lime-elementssource, the Tabulator build innode_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-tableis 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_generatePageButtonmarks the current page with a CSS class and setsaria-labelandtitle— there is noaria-currentin it. A screen-reader user can operate the paginator without ever being told which page they are on.limel-paginationrenders the control as a labelled<nav>and marks the current page witharia-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, andLocalize.bindonly ever pushes intobindings; 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 withpaginationButtonCount: 0only 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_pagereturned byajaxRequestFunc, so the only way to refresh them is areplaceData()round trip that rebuilds every row, flickers, and loses vertical scroll — whichrefreshRemotePaginatorthen 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-paginationtells 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-paginationreaches 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-tablerenders 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 thelast_pagevalue returned byajaxRequestFunc, not fromsetMaxPage— so whentotalRowsorpageSizechange after init, the only way to refresh the buttons is to force a wholereplaceData()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.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 withorderon the footer — which drags the aggregates up with it, which is then pushed back down withposition: 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:
paginationElement: <a detached div>ingetPaginationOptions()(table.tsx:880-891).Page.js:404-405assigns it as the paginator's container, andPage.js:433then skipsfooterAppend, so Tabulator's controls are built into a node that is never in the document.<limel-pagination>in the table's JSX withpageSizeandtotalItems, and feed itspagefrom Tabulator's current page rather than from the table's ownpageprop.limel-paginationis 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.onGoToPagetothis.tabulator.setPage(event.detail.page).handlePageLoaded(table.tsx:968) records the new page and keeps emitting the existingchangePage, 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
datain the table. It is workable —limel-paginationdeliberately slices nothing (pagination.tsx:26-29), the event already carriesoffsetandlimit, 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 thepageLoadedhook thatchangePageis 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 nopageSize(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.
totalRowsneeds to be nullable.limel-paginationreadstotalItems: nullas the count has not arrived yet and holds the control's shape while it is in flight;0means empty.totalRowsis typed plainnumber(table.tsx:112-113), so a consumer mid-fetch can only say0orundefined.limec-system-health-centeris in exactly that state — itstotalRowsgetter returns0untilproviderCountsloads, while the page is already rendered. Widening tonumber | nulland passing it straight through is additive for every existing consumer, and it makes the state expressible for the first time.limec-list-viewalready documents and dodges this trap with a nullablepaginationTotal; 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()usesthis.totalRows ?? this.data.length(table.tsx:1148), so an explicit0stays0.calculatePageCount()usesif (!total) { total = this.data.length; }(table.tsx:1065-1071), so an explicit0becomesdata.length. Whichever expression feedstotalItemsdecides what the control shows: withtotalRows={0}, 25 rows of local data andpageSize10, one says one page and the other says three. Both CRM call sites that can pass0are 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 bycalculatePageCount, thehas-paginationclass andtotalItemsalike, 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@privatebut real:limec-table-viewuses 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 thatlimel-paginationalways renders by design, so this hatch is the only way those two surfaces get the old behaviour.4.
goToPagemust be stopped at the table's boundary.limel-pagination's event bubbles and composes, so one rendered inside the table's shadow root firesgoToPageat the table's own consumers, retargeted to look like it came from<limel-table>. It is not inHTMLLimelTableElementEventMap, so it would be an event consumers can receive but cannot legitimately bind — invisible to the readme, untyped inaddEventListener, unavailable as a JSX prop. Callevent.stopPropagation()first thing in the handler, exactly asselectAllOnChangealready does for the innerlimel-checkbox(table.tsx:1017-1020) and aslimec-list-viewdoes for this very event. The table keepschangePageas 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-539returns a rejected promise after aconsole.warnwhen a local-mode page is outside1..max, andpageChangedalready callsthis.tabulator.setPage(this.page)unawaited and uncaught (table.tsx:342). DrivingsetPagefrom user clicks widens the window:limel-paginationderives its page count fromtotalItemswhile Tabulator derivesmaxfrom the data it holds, so a consumer settingtotalRowslarger thandata.lengthin local mode turns a click into an unhandled rejection. Attach a.catch()to both call sites and keepupdateMaxPage()(table.tsx:723-725) fed from the same number astotalItems.Because the control is fed from Tabulator's page, a rejected
setPageis at least not visible to the user:pageLoadednever 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 oftabulator-paginator.scss(76-147) exist to style thedata-page='first'|'last'arrows.limel-paginationhas 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
paginationCounterandpaginationSizeSelectorboth default tofalse(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.paginationButtonCountdefaults to5, which is exactlyVISIBLE_PAGESinpagination.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-paginationhandles 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
totalRowstonumber | nulland resolve the two totals accessors into one. Independently useful, and it ships without touching the control.paginationElement, wireonGoToPage→setPage, stop the event at the boundary, catch thesetPagerejection.refreshRemotePaginatorand thepaginationLocationorder workaround — each in its own commit, since dead-code removal is the change most likely to prompt "why is this going?".refreshRemotePaginatorand 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.
totalRowskept its type. Widening it tonumber | nullturned out to be unnecessary. Alocaltable pages the rows indataand ignores a total altogether, and aremotetable with rows on screen and no usable total already reports its count as unknown — so a consumer never has to saynullto express the state.limec-system-health-center's0-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.
paginationElementkeeps them out of the document, but_setPageButtonsgoes on building up to five of them on every run of the pipeline, and each one registers alangBindcallback thatLocalize.bindnever releases.paginationButtonCount: 0leaves 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.
paginationSizeis read once at construction, so changingpageSizeleft 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.setPageSizere-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.tsholds the counting rules and the two warning texts, with no Tabulator and no Stencil in them;table-pagination.tsholds the class that drives Tabulator and keeps the control in step. This follows the splitselection.tsandtable-selection.tsalready use.table.tsxkeeps the props, the state the control is drawn from, the watchers and the JSX.Not in scope
Whether
limel-tableshould keep Tabulator at all. This issue assumes it does.