import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Pagination } from './pagination';
import { PAGINATION_CHANGE_EVENT, type PaginationChangeEvent } from './pagination.types';
const MARKUP = `
`;
function setup(): HTMLElement {
document.body.innerHTML = MARKUP;
return document.body.firstElementChild as HTMLElement;
}
function pageButtons(root: HTMLElement): HTMLButtonElement[] {
return Array.from(root.querySelectorAll('[data-c42-pagination-page]'));
}
function pageNumbers(root: HTMLElement): number[] {
return pageButtons(root).map((b) => Number(b.dataset.page));
}
function ellipses(root: HTMLElement): HTMLElement[] {
return Array.from(root.querySelectorAll('[data-c42-pagination-ellipsis]'));
}
function prev(root: HTMLElement): HTMLButtonElement {
return root.querySelector('[data-c42-pagination-prev]')!;
}
function next(root: HTMLElement): HTMLButtonElement {
return root.querySelector('[data-c42-pagination-next]')!;
}
describe('Pagination', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('sets aria-label on the nav root', () => {
const root = setup();
new Pagination(root, { total: 10 });
expect(root.getAttribute('aria-label')).toBe('Pagination');
});
it('renders all pages without ellipsis when they fit', () => {
const root = setup();
new Pagination(root, { total: 5 });
expect(pageNumbers(root)).toEqual([1, 2, 3, 4, 5]);
expect(ellipses(root)).toHaveLength(0);
});
it('renders a trailing ellipsis near the start of a long range', () => {
const root = setup();
new Pagination(root, { total: 10, page: 1 });
expect(pageNumbers(root)).toEqual([1, 2, 3, 4, 5, 10]);
expect(ellipses(root)).toHaveLength(1);
});
it('renders ellipses on both sides in the middle of a long range', () => {
const root = setup();
new Pagination(root, { total: 10, page: 6 });
expect(pageNumbers(root)).toEqual([1, 5, 6, 7, 10]);
expect(ellipses(root)).toHaveLength(2);
});
it('honors siblingCount and boundaryCount', () => {
const root = setup();
new Pagination(root, { total: 20, page: 10, siblingCount: 2, boundaryCount: 2 });
expect(pageNumbers(root)).toEqual([1, 2, 8, 9, 10, 11, 12, 19, 20]);
expect(ellipses(root)).toHaveLength(2);
});
it('marks the current page with aria-current and active state', () => {
const root = setup();
new Pagination(root, { total: 10, page: 3 });
const active = pageButtons(root).find((b) => b.getAttribute('aria-current') === 'page')!;
expect(active.dataset.page).toBe('3');
expect(active.dataset.state).toBe('active');
});
it('disables prev on the first page', () => {
const root = setup();
new Pagination(root, { total: 10, page: 1 });
expect(prev(root).hasAttribute('disabled')).toBe(true);
expect(prev(root).hasAttribute('data-disabled')).toBe(true);
expect(next(root).hasAttribute('disabled')).toBe(false);
});
it('disables next on the last page', () => {
const root = setup();
new Pagination(root, { total: 10, page: 10 });
expect(next(root).hasAttribute('disabled')).toBe(true);
expect(next(root).hasAttribute('data-disabled')).toBe(true);
expect(prev(root).hasAttribute('disabled')).toBe(false);
});
it('changes page on page-button click and emits', () => {
const root = setup();
const pager = new Pagination(root, { total: 10, page: 1 });
const spy = vi.fn();
pager.on(PAGINATION_CHANGE_EVENT, spy);
pageButtons(root).find((b) => b.dataset.page === '3')!.click();
expect(pager.page).toBe(3);
expect(spy).toHaveBeenCalledOnce();
expect(spy.mock.calls[0][0].detail.page).toBe(3);
});
it('navigates with prev/next buttons', () => {
const root = setup();
const pager = new Pagination(root, { total: 10, page: 5 });
next(root).click();
expect(pager.page).toBe(6);
prev(root).click();
expect(pager.page).toBe(5);
});
it('supports next() and prev() methods', () => {
const root = setup();
const pager = new Pagination(root, { total: 3, page: 1 });
pager.next();
expect(pager.page).toBe(2);
pager.prev();
expect(pager.page).toBe(1);
});
it('clamps out-of-range pages and is a no-op when unchanged', () => {
const root = setup();
const pager = new Pagination(root, { total: 5, page: 1 });
const spy = vi.fn();
pager.on(PAGINATION_CHANGE_EVENT, spy);
pager.setPage(99);
expect(pager.page).toBe(5);
pager.setPage(-3);
expect(pager.page).toBe(1);
pager.setPage(1);
expect(pager.page).toBe(1);
expect(spy).toHaveBeenCalledTimes(2);
});
it('exposes page and total getters', () => {
const root = setup();
const pager = new Pagination(root, { total: 8, page: 4 });
expect(pager.page).toBe(4);
expect(pager.total).toBe(8);
});
it('throws when the list is missing', () => {
document.body.innerHTML = `
`;
const root = document.body.firstElementChild as HTMLElement;
expect(() => new Pagination(root, { total: 5 })).toThrow(/data-c42-pagination-list/);
});
it('throws when prev is missing', () => {
document.body.innerHTML = `
`;
const root = document.body.firstElementChild as HTMLElement;
expect(() => new Pagination(root, { total: 5 })).toThrow(/data-c42-pagination-prev/);
});
it('throws when next is missing', () => {
document.body.innerHTML = `
`;
const root = document.body.firstElementChild as HTMLElement;
expect(() => new Pagination(root, { total: 5 })).toThrow(/data-c42-pagination-next/);
});
it('throws when total is invalid', () => {
const root = setup();
expect(() => new Pagination(root, { total: 0 })).toThrow(/total/);
});
it('removes listeners on destroy', () => {
const root = setup();
const pager = new Pagination(root, { total: 10, page: 1 });
pager.destroy();
next(root).click();
expect(pager.page).toBe(1);
pageButtons(root)[2]!.click();
expect(pager.page).toBe(1);
});
});