@yaebal/pagination

paginated lists over any data source. renders a page as a telegram message with ◀ / ▶ navigation (plus optional ⏮ ⏭ jumps and a counter), edits it in place on every press, and optionally turns each item into a tappable button with a typed onSelect. sources can be a plain array or a lazy { fetch, count? } that paginates in the database; a typed payload parameterizes one list instance per category, owner, or filter.

installation

terminal
pnpm add @yaebal/pagination

basic usage

pagination(options) returns a Pagination object. install list.plugin() once to handle the button presses, then list.send(ctx) renders a page into the chat. the plugin adds nothing to the context — everything lives on the returned object.

bot.ts
import { createBot } from "yaebal";
import { pagination } from "@yaebal/pagination";

const changelog = [
  "0.1 — first light",
  "0.2 — plugins everywhere",
  "0.3 — files and media",
  "0.4 — scenes",
  "0.5 — i18n",
  "0.6 — playground",
];

const releases = pagination({
  id: "rel",
  pageSize: 3,
  source: () => changelog,
  line: (entry) => entry,
  counter: true, // a "N/M" button between ◀ ▶ — pressing it refreshes the page
});

const bot = createBot(process.env.BOT_TOKEN!)
  .install(releases.plugin());

bot.command("releases", (ctx) => releases.send(ctx));

bot.start();

lazy sources

an array source re-materializes the whole list on every press — fine in memory, wasteful over a database. a lazy source is asked for exactly one page: fetch receives { offset, limit, page, ctx, payload }. with count the totals are exact and fetch + count run in parallel.

lazy.ts
const users = pagination({
  id: "users",
  pageSize: 10,
  source: {
    // the plugin asks for one page — paginate in the database
    fetch: ({ offset, limit }) => db.user.findMany({ skip: offset, take: limit }),
    count: () => db.user.count(), // optional — makes "page N/M" exact
  },
  line: (u, i) => `${i + 1}. ${u.name}`,
});

without count the plugin probes limit + 1 rows per page instead of counting — the cheapest way to know whether ▶ should exist.

probe.ts
const feed = pagination({
  id: "feed",
  source: {
    // no count: the plugin fetches limit + 1 rows — the extra row only
    // answers "is there a next page", it is never rendered
    fetch: ({ offset, limit }) => db.events(offset, limit),
  },
  line: (e) => e.title,
});
// the header shows "page N" until the reader hits the end — then the
// real total is known and it upgrades to "page N/M"

items as buttons

item renders each row into the inline keyboard (wrapped into columns per row); onSelect handles the tap and the callback query is answered automatically. line and item combine freely — text list, button list, or both.

shop.ts
const shop = pagination({
  id: "shop",
  pageSize: 4,
  columns: 2,                                    // item buttons per row
  source: () => products,
  line: (p) => `${p.name} — $${p.price}`,       // optional alongside item
  item: (p) => ({ label: p.name, id: p.id }),    // each item is a button
  onSelect: (ctx, sel) => {
    // sel.id keeps its type: numbers stay numbers, strings stay strings
    // sel.page lets a detail card link back to the exact page
    return showCard(ctx, sel.id, sel.page);
  },
});
bot.ts
import { createBot } from "yaebal";
import { pagination } from "@yaebal/pagination";

const products = [
  { id: 11, name: "neon hoodie" },
  { id: 12, name: "field guide" },
  { id: 13, name: "keychain" },
  { id: 14, name: "plugin pass" },
];

const shop = pagination({
  id: "shop",
  pageSize: 2,
  columns: 2,
  source: () => products,
  item: (p) => ({ label: p.name, id: p.id }),
  onSelect: (ctx, sel) =>
    ctx.send(`you picked #${sel.id} (from page ${sel.page + 1})`),
});

const bot = createBot(process.env.BOT_TOKEN!)
  .install(shop.plugin());

bot.command("shop", (ctx) => shop.send(ctx));

bot.start();

returning a raw InlineKeyboardButton from item passes it through verbatim — url lists, web apps, or custom callback_data routed by your own handlers.

links.ts
const links = pagination({
  id: "docs",
  source: () => articles,
  // a raw InlineKeyboardButton passes through verbatim — url, web_app,
  // or your own callback_data instead of the built-in select
  item: (a) => ({ text: a.title, url: a.href }),
});

typed payload

payload is a @yaebal/callback-data schema fragment baked into every button this list emits. one instance serves every parameterization; the values come back decoded in source, header, filter, and onSelect. when the schema has required fields, send requires the payload at compile time.

genres.ts
import { field } from "@yaebal/callback-data";

const byGenre = pagination({
  id: "genre",
  payload: { genre: field.enum(["fantasy", "sci-fi", "poetry"]) },
  source: {
    fetch: ({ offset, limit, payload }) => db.books(payload.genre, offset, limit),
    count: (ctx, payload) => db.countBooks(payload.genre),
  },
  header: (info) => `${info.payload.genre} — page ${info.page + 1}/${info.pages}`,
  line: (b) => b.title,
});

// the payload rides the buttons, not server state — it survives restarts
bot.command("fantasy", (ctx) =>
  byGenre.send(ctx, { payload: { genre: "fantasy" } }));

ownership

in a group everyone sees the same message — filter decides who may press its buttons, and denied is the toast the rest get. because the filter receives the decoded payload, ownership travels inside the buttons and survives restarts with zero server state.

ownership.ts
const mine = pagination({
  id: "mine",
  payload: { owner: Number },
  source: { fetch: ({ offset, limit, payload }) => myRows(payload.owner, offset, limit) },
  line: (r) => r.title,
  // the decoded payload is passed to filter — ownership rides the buttons
  filter: (ctx, payload) => ctx.from?.id === payload.owner,
  denied: "not your list",
});

bot.command("mine", (ctx) =>
  mine.send(ctx, { payload: { owner: ctx.from!.id } }));

rendering and navigation

headers, lines, and the empty state accept plain strings or format results — entities are merged and offset-shifted for you. page text is clamped to telegram's 4096-char limit with entities clipped to the cut.

library.ts
import { bold, format, italic } from "@yaebal/core";

const library = pagination({
  id: "lib",
  source: () => books,
  header: (info) => format`${bold("library")} — ${info.count} books`,
  line: (b) => format`${bold(b.title)} — ${italic(b.author)}`,
  empty: "no books yet",
  labels: { prev: "‹", next: "›" },   // and first/last for ⏮ ⏭
  counter: true,                      // "N/M" button — doubles as refresh
  firstLast: true,                    // ⏮ ⏭ jump buttons
  keyboard: (kb) => kb.row().text("✖ close", "lib:close"),
});

embedding: view, edit, button

the rendered page is not locked inside sendview() returns it for your own delivery, edit() re-renders an existing message, and button() makes any keyboard open the list in place.

embedding.ts
// view(): render without sending — text, entities, markup, items, page info
const v = await users.view(ctx, { page: 2 });
await ctx.sendPhoto(cover, { caption: v.text, reply_markup: v.markup });

// edit(): re-render in place — the current callback message by default,
// or an explicit target
await users.edit(ctx, { page: 0 });
await users.edit(ctx, { page: 0, chatId, messageId });

// button(): a jump-to-page button for any keyboard. pressing it edits
// that message into the list — menus morph into lists in place
new InlineKeyboard()
  .add(byGenre.button("fantasy", { payload: { genre: "fantasy" } }))
  .add(byGenre.button("sci-fi", { payload: { genre: "sci-fi" } }));

api

exportkinddescription
pagination(options)functionbuild a Pagination(options: PaginationOptions<T, P>) => Pagination<T, P>
Paginationinterfaceplugin / send / edit / view / button
PaginationOptionsinterfaceconfiguration — see the options table
PageInfointerfacepage, pages?, count?, hasPrev, hasNext, payload
PageQueryinterfacewhat a lazy fetch receives
PageViewinterfaceview() result — text, entities?, markup, items + PageInfo
SelectEventinterfaceonSelect event — id, page, payload
PaginationIteminterface{ label, id } returned by item
PaginationContexttypeContext narrowed to a callback query
LazySource / ArraySource / Sourcetypesthe source shapes

Pagination object

memberreturnsdescription
plugin()Pluginhandles this list's ◀ ▶ ⏮ ⏭ and item presses — bot.install(list.plugin())
send(ctx, pageOrOpts?)Promise<Message>send the page as a new message; send(ctx, 2) or send(ctx, { page, payload })
edit(ctx, pageOrOpts?)Promise<Message | true>re-render in place — the callback message, or an explicit chatId/messageId/inlineMessageId; a no-op edit resolves true
view(ctx, pageOrOpts?)Promise<PageView>render without sending
button(label, pageOrOpts?)InlineKeyboardButtona button that opens the list at page when pressed, from any keyboard

options

optiondefaultdescription
idunique namespace for the callback_data. must not contain : or \
source(ctx, payload) => T[] or { fetch, count? }
line(item, index, ctx) => string | FormatResult — a text row; index is global
item(item, index) => string | { label, id } | InlineKeyboardButton — a button row (at least one of line/item is required)
onSelect(ctx, { id, page, payload }) => unknown — item tap handler
pageSize5items per page
columns1item buttons per keyboard row
header"page N/M"(info, ctx) => string | FormatResult; return "" to omit
empty"nothing here"shown when the page has no items — value or (info, ctx) =>
payloadcallback-data schema fragment carried by every button ($-names reserved)
labels◀ ▶ ⏮ ⏭navigation button labels — { prev, next, first, last }
counterfalse"N/M" button between prev/next; pressing it refreshes the page. pass a function to format it
firstLastfalse⏮ ⏭ jump buttons (⏭ needs a known total)
keyboard(kb, info, ctx) => InlineKeyboard | void — append rows or reshape the keyboard
filter(ctx, payload) => boolean — gate presses
deniedtoast shown when filter rejects

failure model

built for the ways pagination actually fails in production: every callback query is answered in a finally, so the client spinner never hangs — even when your source throws. a double-tap produces telegram's message is not modified — swallowed, never an error. a press on a message older than 48 hours can't be edited — the page is re-sent as a fresh message instead. forged or stale callback data (fractional, negative, or out-of-range pages — telegram warns clients can send arbitrary callback_data) is clamped, never crashes. inline-mode messages are edited through inline_message_id, business-chat messages through their connection.

production notes

each id must be unique — two lists with the same id intercept each other's presses. every button press re-renders the page, so give hot lists a lazy source (and a cheap count), or rate-limit with @yaebal/ratelimiter. callback_data is capped at 64 bytes: keep id, item ids, and payload values short — pack throws a clear RangeError when a button would not fit. schema changes to payload invalidate buttons already on screen (they safely fall through to other handlers).

testing

drive the list with @yaebal/test actors: clickByText("▶") presses real buttons, lastBotMessage mirrors the edits, and onApi + apiError exercise the failure paths. see packages/pagination/src/index.test.ts for the full pattern.

list.test.ts
import { apiError, createTestEnv } from "@yaebal/test";

const env = createTestEnv(new Composer().install(list.plugin()));
const user = env.createUser();

await user.sendCommand("list");
const bubble = env.lastBotMessage({ withReplyMarkup: true })!;

await user.on(bubble).clickByText("▶");
assert.match(env.lastApiCall("editMessageText")?.params?.text, /page 2/);

// the failure model is testable too
env.onApi("editMessageText", apiError(400, "Bad Request: message is not modified"), { times: 1 });
await user.on(bubble).clickByText("▶"); // swallowed — never reaches onError

related

built on @yaebal/callback-data (the wire format and the payload schema) and @yaebal/keyboard (the markup; both are regular dependencies — installed with the plugin). runnable bots: examples/pagination (lazy sources, genres via payload, ownership) and examples/commerce-suite (a shop catalog with selectable products). for stateful multi-screen dialogs, reach for @yaebal/morda instead.