Skip to content

Custom rendering

Take over a paginator's whole message with render(). Covers a page with no buttons on it, and placing the nav buttons back yourself.

By default a paginator sends one container with your items as lines and a row of nav buttons under them. That's the whole layout. A page with a header, a thumbnail per entry, or the buttons somewhere else needs more.

render takes that job. It replaces the page seedcord would have built, nav row included. seedcord adds nothing around what you return.

The function receives two arguments. view is the PageView the source returned, holding this page's items. controls is a PaginatorControls, which builds nav buttons already pointed at the right page.

A page with no buttons on it

Start with a render that ignores controls and returns a string.

src/handlers/Feed.tshover for typestap for types, arrow keys walk the tokens
import {
    
function ButtonRoute<const Defs extends readonly AnyCustomId[]>(
    ...defs: Defs
): <TCtor extends AnyHandlerCtor>(
    constructor: AssertComponentRoute<InteractionKind.Button, Defs, TCtor>
) => void
ButtonRoute
,
class CursorSource<Item>CursorSource, class Paginator<Item, const Prefix extends string>Paginator,
class SlashHandler<
    Route extends keyof SlashRegistry,
    Cache extends CacheType = CacheFor<Route>
>
SlashHandler
,
function SlashRoute<const Route extends keyof SlashRegistry>(
    ...routes: Route[]
): <TCtor extends AnyHandlerCtor>(
    constructor: AssertSlashRoute<Route, TCtor>
) => void
SlashRoute
} from '@seedcord/gateway'; interface interface PostPost { Post.id: numberid: number; Post.text: stringtext: string; } export const const Feed: Paginator<Post, "feed">Feed = new new Paginator<Post, "feed">(config: PaginatorConfig<Post, "feed", PageContext>): Paginator<Post, "feed">Paginator({ PaginatorConfig<Post, "feed", PageContext>.prefix: "feed"prefix: 'feed', PaginatorConfig<Post, "feed", PageContext>.source: PageSourceBase<Post, PageContext>source: new
new CursorSource<Post>(fetch: (ctx: PageContext, page: number, perPage: number) => Promisable<{
    items: readonly Post[];
    hasNext: boolean;
}>, opts?: {
    perPage?: number;
}): CursorSource<Post>
CursorSource
((_ctx: PageContext_ctx, page: numberpage, perPage: numberperPage) =>
function fetchPosts(
    page: number,
    perPage: number
): Promise<{
    items: Post[];
    hasNext: boolean;
}>
fetchPosts
(page: numberpage, perPage: numberperPage)
), PaginatorConfig<Post, "feed", PageContext>.render?: PageRender<Post>render: (view: PageView<Post>view) => `Page ${view: PageView<Post>view.PageView<Item>.page: numberpage + 1}, ${view: PageView<Post>view.PageView<Post>.items: Post[]items.Array<Post>.length: numberlength} posts` }); @ButtonRoute<readonly [PageCursor<"feed">]>(defs_0: PageCursor<"feed">): <TCtor>(constructor: AssertComponentRoute<InteractionKind.Button, readonly [PageCursor<"feed">], TCtor>) => voidButtonRoute(const Feed: Paginator<Post, "feed">Feed.PaginatorBase<Post, "feed", PageContext>.cursor: PageCursor<"feed">cursor) export class class FeedNavFeedNav extends const Feed: Paginator<Post, "feed">Feed.Paginator<Post, "feed">.Handler: PaginatorHandlerCtor<"feed">Handler {} @SlashRoute<"feed">(...routes: "feed"[]): <TCtor>(constructor: AssertSlashRoute<"feed", TCtor>) => voidSlashRoute('feed') export class class FeedListFeedList extends
class SlashHandler<
    Route extends keyof SlashRegistry,
    Cache extends CacheType = CacheFor<Route>
>
SlashHandler
<'feed'> {
public async FeedList.execute(): Promise<void>execute(): interface Promise<T>Promise<void> { await const Feed: Paginator<Post, "feed">Feed.Paginator<Post, "feed">.start(handler: RepliableHandler<Repliables>, n?: number): Promise<SentMessage>start(this); } }

If you run that, you get one line of text with no way to reach page two. FeedNav is wired correctly. It never fires, since no button on the message carries its route. Everything below puts the buttons back.

A string is one of three shapes render can return.

what you returnwhat seedcord sends
a stringone text component holding it
an array of componentsthose components, in order
a whole reply objectexactly that, so files and allowedMentions pass through

Putting the buttons back

controls.row() returns a finished action row. Name the keys you want, in the order you want them on screen.

src/components/FeedCard.ts
import { BuilderComponent } from '@seedcord/gateway';

import type {
    PageView,
    PaginatorControls
} from '@seedcord/gateway';

interface Post {
    id: number;
    text: string;
}

export class FeedCard extends BuilderComponent<'container'> {
    constructor(view: PageView<Post>, controls: PaginatorControls) {
        super('container');

        const lines = view.items
            .map((post) => `- ${post.text}`)
            .join('\n');

        this.instance
            .addTextDisplayComponents((text) =>
                text.setContent(lines)
            )
            .addActionRowComponents(
                controls.row('prev', 'indicator', 'next')
            );
    }
}

Now render takes both arguments and hands the card back.

export const Feed = new Paginator({
    prefix: 'feed',
    source: new CursorSource((_ctx, page, perPage) =>
        fetchPosts(page, perPage)
    ),
    render: (view, controls) => [
        new FeedCard(view, controls).component
    ]
});

The controls.row('prev', 'indicator', 'next') call inside FeedCard builds each button with its target page already set, and disables it at the ends of the list. Prev on page one comes back greyed out with nothing left for you to wire.

The control keys

row() and button() both take one of five keys.

controls.row('
  • first
  • prev
  • indicator
  • next
  • last

The indicator is a disabled button showing the page text, Page 2 of 4 when the source counted and Page 2 when it couldn't. A cursor source never counts, so last has no page to point at and controls always disables it there. first still works, since page zero needs no total. The fence above skips both and keeps three keys, the same row seedcord's default draws for a cursor source.

An ArraySource knows its total, so a row there can take every key.

row() throws on an empty list, on more than five keys, and on the same key twice. Two copies of one control get the same custom id, which Discord rejects.

One button at a time

row() builds every button with its default label and color. controls.button() returns them one at a time so you can pass cosmetics.

this.instance.addActionRowComponents((row) =>
    row.addComponents(
        controls.button('prev', {
            style: ButtonStyle.Primary,
            emoji: { name: '⬅️' }
        }),
        controls.button('indicator'),
        controls.button('next', {
            style: ButtonStyle.Primary,
            emoji: { name: '➡️' }
        })
    )
);
optionwhat it sets
labelthe button text
stylePrimary, Secondary, Success, or Danger
emojian emoji payload, { name } for unicode or { id } for a custom one

An emoji with no label gives an icon-only button, which is what prev and next are above. The indicator keeps its page text either way.

Warning

Call cosmetic setters only. setCustomId overwrites the page the button targets. setStyle with ButtonStyle.Link or ButtonStyle.Premium strips the custom id entirely, since Discord allows no custom id on either style. Any of the three leaves a button that reaches no handler.

If your own header prints the total, check view.totalPages first, since a cursor source leaves it undefined. Sources lists every field on a PageView.

Paging it yourself is the last step, dropping the paginator and keeping the page math.