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 = []; } }