@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
pnpm add @yaebal/paginationbasic 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.
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.
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.
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.
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);
},
});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.
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.
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.
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.
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 send — view() returns it for
your own delivery, edit() re-renders an existing message, and button() makes any keyboard open the list in place.
// 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
| export | kind | description |
|---|---|---|
pagination(options) | function | build a Pagination — (options: PaginationOptions<T, P>) => Pagination<T, P> |
Pagination | interface | plugin / send / edit / view / button |
PaginationOptions | interface | configuration — see the options table |
PageInfo | interface | page, pages?, count?, hasPrev, hasNext, payload |
PageQuery | interface | what a lazy fetch receives |
PageView | interface | view() result — text, entities?, markup, items + PageInfo |
SelectEvent | interface | onSelect event — id, page, payload |
PaginationItem | interface | { label, id } returned by item |
PaginationContext | type | Context narrowed to a callback query |
LazySource / ArraySource / Source | types | the source shapes |
Pagination object
| member | returns | description |
|---|---|---|
plugin() | Plugin | handles 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?) | InlineKeyboardButton | a button that opens the list at page when pressed, from any keyboard |
options
| option | default | description |
|---|---|---|
id | — | unique 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 |
pageSize | 5 | items per page |
columns | 1 | item 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) => |
payload | — | callback-data schema fragment carried by every button ($-names reserved) |
labels | ◀ ▶ ⏮ ⏭ | navigation button labels — { prev, next, first, last } |
counter | false | "N/M" button between prev/next; pressing it refreshes the page. pass a function to format it |
firstLast | false | ⏮ ⏭ jump buttons (⏭ needs a known total) |
keyboard | — | (kb, info, ctx) => InlineKeyboard | void — append rows or reshape the keyboard |
filter | — | (ctx, payload) => boolean — gate presses |
denied | — | toast shown when filter rejects |
failure model
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
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.
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 onErrorrelated
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.