diff --git a/.changeset/odd-dots-greet.md b/.changeset/odd-dots-greet.md new file mode 100644 index 0000000..555dc53 --- /dev/null +++ b/.changeset/odd-dots-greet.md @@ -0,0 +1,5 @@ +--- +'@layerstack/svelte-state': patch +--- + +Add PaginationState diff --git a/packages/svelte-state/src/lib/paginationState.svelte.ts b/packages/svelte-state/src/lib/paginationState.svelte.ts new file mode 100644 index 0000000..5fdf83b --- /dev/null +++ b/packages/svelte-state/src/lib/paginationState.svelte.ts @@ -0,0 +1,97 @@ +import { clamp } from '@layerstack/utils'; + +export type PaginationOptions = { + /** Initial page */ + page?: number; + + /** Number of items per page */ + perPage?: number; + + /** Total number of items */ + total?: number; +}; + +export class PaginationState { + #page: number; + #perPage: number; + #total: number; + + constructor(options: PaginationOptions = {}) { + this.#page = options.page ?? 1; + this.#perPage = options.perPage ?? 25; + this.#total = options.total ?? 0; + } + + get page() { + return this.#page; + } + + set page(value: number) { + // Do not allow page to exceed bounds (ex. call nextPage() when on last page) + this.#page = clamp(value, 1, this.totalPages); + } + + get perPage() { + return this.#perPage; + } + + set perPage(value: number) { + this.#perPage = value; + } + + get total() { + return this.#total; + } + + set total(value: number) { + this.#total = value; + } + + get totalPages() { + return Math.ceil(this.total / this.perPage); + } + + get from() { + return Math.min(this.total, Math.max(0, (this.page - 1) * this.perPage + 1)); + } + + get to() { + return Math.min(this.total, this.page * this.perPage); + } + + get isFirst() { + return this.page === 1; + } + + get isLast() { + return this.page >= this.totalPages; + } + + get hasPrevious() { + return this.page > 1 && this.totalPages > 0; + } + + get hasNext() { + return this.page < this.totalPages; + } + + nextPage = () => { + this.page = this.page + 1; + }; + + prevPage = () => { + this.page = this.page - 1; + }; + + firstPage = () => { + this.page = 1; + }; + + lastPage = () => { + this.page = Math.ceil(this.total / this.perPage); + }; + + slice(data: T[]) { + return data.slice((this.page - 1) * this.perPage, this.page * this.perPage); + } +} diff --git a/packages/svelte-state/src/lib/paginationState.test.svelte.ts b/packages/svelte-state/src/lib/paginationState.test.svelte.ts new file mode 100644 index 0000000..84f600b --- /dev/null +++ b/packages/svelte-state/src/lib/paginationState.test.svelte.ts @@ -0,0 +1,139 @@ +import { describe, it, expect } from 'vitest'; + +import { PaginationState } from './paginationState.svelte.js'; + +describe('PaginationState', () => { + it('should initialize with default values', () => { + const paginationState = new PaginationState(); + expect(paginationState.page).toEqual(1); + expect(paginationState.perPage).toEqual(25); + expect(paginationState.total).toEqual(0); + expect(paginationState.totalPages).toEqual(0); + expect(paginationState.from).toEqual(0); + expect(paginationState.to).toEqual(0); + expect(paginationState.isFirst).toEqual(true); + expect(paginationState.isLast).toEqual(true); + expect(paginationState.hasPrevious).toEqual(false); + expect(paginationState.hasNext).toEqual(false); + }); + + it('should initialize with total only', () => { + const paginationState = new PaginationState({ total: 100 }); + expect(paginationState.page).toEqual(1); + expect(paginationState.perPage).toEqual(25); + expect(paginationState.total).toEqual(100); + expect(paginationState.totalPages).toEqual(4); + expect(paginationState.from).toEqual(1); + expect(paginationState.to).toEqual(25); + expect(paginationState.isFirst).toEqual(true); + expect(paginationState.isLast).toEqual(false); + expect(paginationState.hasPrevious).toEqual(false); + expect(paginationState.hasNext).toEqual(true); + }); + + it('should initialize with page', () => { + const paginationState = new PaginationState({ page: 2, total: 100 }); + expect(paginationState.page).toEqual(2); + expect(paginationState.perPage).toEqual(25); + expect(paginationState.total).toEqual(100); + expect(paginationState.totalPages).toEqual(4); + expect(paginationState.from).toEqual(26); + expect(paginationState.to).toEqual(50); + expect(paginationState.isFirst).toEqual(false); + expect(paginationState.isLast).toEqual(false); + expect(paginationState.hasPrevious).toEqual(true); + expect(paginationState.hasNext).toEqual(true); + }); + + it('should initialize with perPage', () => { + const paginationState = new PaginationState({ perPage: 10, total: 100 }); + expect(paginationState.page).toEqual(1); + expect(paginationState.perPage).toEqual(10); + expect(paginationState.total).toEqual(100); + expect(paginationState.totalPages).toEqual(10); + expect(paginationState.from).toEqual(1); + expect(paginationState.to).toEqual(10); + expect(paginationState.isFirst).toEqual(true); + expect(paginationState.isLast).toEqual(false); + expect(paginationState.hasPrevious).toEqual(false); + expect(paginationState.hasNext).toEqual(true); + }); + + it('should increment page', () => { + const paginationState = new PaginationState({ total: 100 }); + expect(paginationState.page).toEqual(1); + expect(paginationState.from).toEqual(1); + expect(paginationState.to).toEqual(25); + expect(paginationState.isFirst).toEqual(true); + expect(paginationState.isLast).toEqual(false); + expect(paginationState.hasPrevious).toEqual(false); + expect(paginationState.hasNext).toEqual(true); + + paginationState.nextPage(); + expect(paginationState.page).toEqual(2); + expect(paginationState.from).toEqual(26); + expect(paginationState.to).toEqual(50); + expect(paginationState.isFirst).toEqual(false); + expect(paginationState.isLast).toEqual(false); + expect(paginationState.hasPrevious).toEqual(true); + expect(paginationState.hasNext).toEqual(true); + }); + + it('should decrement page', () => { + const paginationState = new PaginationState({ page: 2, total: 100 }); + expect(paginationState.page).toEqual(2); + expect(paginationState.from).toEqual(26); + expect(paginationState.to).toEqual(50); + expect(paginationState.isFirst).toEqual(false); + expect(paginationState.isLast).toEqual(false); + expect(paginationState.hasPrevious).toEqual(true); + expect(paginationState.hasNext).toEqual(true); + + paginationState.prevPage(); + expect(paginationState.page).toEqual(1); + expect(paginationState.from).toEqual(1); + expect(paginationState.to).toEqual(25); + expect(paginationState.isFirst).toEqual(true); + expect(paginationState.isLast).toEqual(false); + expect(paginationState.hasPrevious).toEqual(false); + expect(paginationState.hasNext).toEqual(true); + }); + + it('should clamp page', () => { + const paginationState = new PaginationState({ page: 4, total: 100 }); + expect(paginationState.page).toEqual(4); + expect(paginationState.from).toEqual(76); + expect(paginationState.to).toEqual(100); + expect(paginationState.isFirst).toEqual(false); + expect(paginationState.isLast).toEqual(true); + expect(paginationState.hasPrevious).toEqual(true); + expect(paginationState.hasNext).toEqual(false); + + paginationState.nextPage(); + expect(paginationState.page).toEqual(4); + expect(paginationState.from).toEqual(76); + expect(paginationState.to).toEqual(100); + expect(paginationState.isFirst).toEqual(false); + expect(paginationState.isLast).toEqual(true); + expect(paginationState.hasPrevious).toEqual(true); + expect(paginationState.hasNext).toEqual(false); + }); + + it('should slice data', () => { + const data = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const paginationState = new PaginationState({ perPage: 5, total: data.length }); + expect(paginationState.slice(data)).toEqual([1, 2, 3, 4, 5]); + + paginationState.nextPage(); + expect(paginationState.slice(data)).toEqual([6, 7, 8, 9, 10]); + + paginationState.nextPage(); + expect(paginationState.slice(data)).toEqual([6, 7, 8, 9, 10]); // clamped + + paginationState.prevPage(); + expect(paginationState.slice(data)).toEqual([1, 2, 3, 4, 5]); + + paginationState.prevPage(); + expect(paginationState.slice(data)).toEqual([1, 2, 3, 4, 5]); // clamped + }); +}); diff --git a/sites/docs/src/routes/_NavMenu.svelte b/sites/docs/src/routes/_NavMenu.svelte index f908238..e4cc01c 100644 --- a/sites/docs/src/routes/_NavMenu.svelte +++ b/sites/docs/src/routes/_NavMenu.svelte @@ -19,7 +19,13 @@ 'styles', ]; - const state = ['MediaQueryPresets', 'SelectionState', 'TimerState', 'UniqueState']; + const state = [ + 'MediaQueryPresets', + 'PaginationState', + 'SelectionState', + 'TimerState', + 'UniqueState', + ]; const stores = [ 'changeStore', diff --git a/sites/docs/src/routes/docs/svelte-state/PaginationState/+page.svelte b/sites/docs/src/routes/docs/svelte-state/PaginationState/+page.svelte new file mode 100644 index 0000000..d65d7ca --- /dev/null +++ b/sites/docs/src/routes/docs/svelte-state/PaginationState/+page.svelte @@ -0,0 +1,28 @@ + + +

Usage

+ + diff --git a/sites/docs/src/routes/docs/svelte-state/PaginationState/+page.ts b/sites/docs/src/routes/docs/svelte-state/PaginationState/+page.ts new file mode 100644 index 0000000..c081072 --- /dev/null +++ b/sites/docs/src/routes/docs/svelte-state/PaginationState/+page.ts @@ -0,0 +1,14 @@ +import source from '$svelte-state/paginationState.svelte.js?raw'; +import pageSource from './+page.svelte?raw'; + +export async function load() { + return { + meta: { + source, + pageSource, + description: + 'Manage pagination state including current page and page navigation (next/previous/first/last). See related Paginate/Pagination components', + related: ['components/Paginate', 'components/Pagination'], + }, + }; +}