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); }); });