import { uid } from '../shared/dom';
import {
PAGINATION_CHANGE_EVENT,
type PaginationChangeDetail,
type PaginationOptions,
} from './pagination.types';
const SELECTORS = {
list: '[data-c42-pagination-list]',
prev: '[data-c42-pagination-prev]',
next: '[data-c42-pagination-next]',
} as const;
type PaginationItem = number | 'ellipsis';
/**
* Headless pagination controller. Renders page buttons with ellipsis into a
* provided list element, wires ARIA and prev/next state to existing markup; it
* never applies visual styles.
*
* Markup:
* ```html
*
* ```
*/
export class Pagination {
private readonly root: HTMLElement;
private readonly listEl: HTMLElement;
private readonly prevEl: HTMLElement;
private readonly nextEl: HTMLElement;
private readonly totalPages: number;
private readonly siblingCount: number;
private readonly boundaryCount: number;
private current = 1;
private cleanups: Array<() => void> = [];
private pageCleanups: Array<() => void> = [];
constructor(root: HTMLElement, options: PaginationOptions) {
const listEl = root.querySelector(SELECTORS.list);
if (!listEl) {
throw new Error('[42/pagination] Needs a [data-c42-pagination-list] element.');
}
const prevEl = root.querySelector(SELECTORS.prev);
if (!prevEl) {
throw new Error('[42/pagination] Needs a [data-c42-pagination-prev] element.');
}
const nextEl = root.querySelector(SELECTORS.next);
if (!nextEl) {
throw new Error('[42/pagination] Needs a [data-c42-pagination-next] element.');
}
if (!options || !Number.isFinite(options.total) || options.total < 1) {
throw new Error('[42/pagination] `total` must be a number >= 1.');
}
this.root = root;
this.listEl = listEl;
this.prevEl = prevEl;
this.nextEl = nextEl;
this.totalPages = Math.floor(options.total);
this.siblingCount = options.siblingCount ?? 1;
this.boundaryCount = options.boundaryCount ?? 1;
this.current = this.clamp(options.page ?? 1);
this.init();
}
private clamp(page: number): number {
if (!Number.isFinite(page)) {
return 1;
}
return Math.min(Math.max(Math.floor(page), 1), this.totalPages);
}
private init(): void {
if (!this.root.hasAttribute('aria-label')) {
this.root.setAttribute('aria-label', 'Pagination');
}
const onPrev = (): void => this.prev();
const onNext = (): void => this.next();
this.prevEl.addEventListener('click', onPrev);
this.nextEl.addEventListener('click', onNext);
this.cleanups.push(() => {
this.prevEl.removeEventListener('click', onPrev);
this.nextEl.removeEventListener('click', onNext);
});
this.render();
}
/** Build the page/ellipsis sequence using boundary and sibling counts. */
private range(): PaginationItem[] {
const total = this.totalPages;
const boundary = Math.max(0, this.boundaryCount);
const sibling = Math.max(0, this.siblingCount);
const startPages = this.numbers(1, Math.min(boundary, total));
const endPages = this.numbers(Math.max(total - boundary + 1, boundary + 1), total);
const siblingsStart = Math.max(
Math.min(this.current - sibling, total - boundary - sibling * 2 - 1),
boundary + 2,
);
const siblingsEnd = Math.min(
Math.max(this.current + sibling, boundary + sibling * 2 + 2),
endPages.length > 0 ? endPages[0]! - 2 : total - 1,
);
const items: PaginationItem[] = [...startPages];
if (siblingsStart > boundary + 2) {
items.push('ellipsis');
} else if (boundary + 1 < total - boundary) {
items.push(boundary + 1);
}
items.push(...this.numbers(siblingsStart, siblingsEnd));
if (siblingsEnd < total - boundary - 1) {
items.push('ellipsis');
} else if (total - boundary > boundary) {
items.push(total - boundary);
}
items.push(...endPages);
return items;
}
private numbers(start: number, end: number): number[] {
const length = Math.max(end - start + 1, 0);
return Array.from({ length }, (_, index) => start + index);
}
private render(): void {
this.pageCleanups.forEach((fn) => fn());
this.pageCleanups = [];
this.listEl.replaceChildren();
this.range().forEach((item) => {
if (item === 'ellipsis') {
const span = document.createElement('span');
span.dataset.c42PaginationEllipsis = '';
span.setAttribute('aria-hidden', 'true');
span.textContent = '…';
this.listEl.append(span);
return;
}
const button = document.createElement('button');
button.type = 'button';
button.dataset.c42PaginationPage = '';
button.dataset.page = String(item);
button.textContent = String(item);
button.id = button.id || uid('pagination-page');
button.setAttribute('aria-label', `Go to page ${item}`);
const isActive = item === this.current;
if (isActive) {
button.setAttribute('aria-current', 'page');
button.dataset.state = 'active';
} else {
button.dataset.state = 'inactive';
}
const onClick = (): void => this.setPage(item);
button.addEventListener('click', onClick);
this.pageCleanups.push(() => button.removeEventListener('click', onClick));
this.listEl.append(button);
});
const atStart = this.current <= 1;
const atEnd = this.current >= this.totalPages;
this.prevEl.toggleAttribute('disabled', atStart);
this.prevEl.toggleAttribute('data-disabled', atStart);
this.nextEl.toggleAttribute('disabled', atEnd);
this.nextEl.toggleAttribute('data-disabled', atEnd);
this.root.dataset.state = `page-${this.current}`;
}
private emit(): void {
const detail: PaginationChangeDetail = { page: this.current };
this.root.dispatchEvent(new CustomEvent(PAGINATION_CHANGE_EVENT, { detail, bubbles: true }));
}
/** Go to a page. Clamps to `1..total` and is a no-op if unchanged. */
setPage(page: number): void {
const next = this.clamp(page);
if (next === this.current) {
return;
}
this.current = next;
this.render();
this.emit();
}
/** Go to the next page. No-op on the last page. */
next(): void {
this.setPage(this.current + 1);
}
/** Go to the previous page. No-op on the first page. */
prev(): void {
this.setPage(this.current - 1);
}
get page(): number {
return this.current;
}
get total(): number {
return this.totalPages;
}
/** Subscribe to a DOM event on the root element. Returns an unsubscribe fn. */
on(event: string, handler: (event: E) => void): () => void {
const listener = handler as EventListener;
this.root.addEventListener(event, listener);
const off = (): void => this.root.removeEventListener(event, listener);
this.cleanups.push(off);
return off;
}
destroy(): void {
this.pageCleanups.forEach((fn) => fn());
this.pageCleanups = [];
this.cleanups.forEach((fn) => fn());
this.cleanups = [];
}
}