From dcd2ce7469527ae510d3bd3764114c23bebfea0b Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Tue, 14 Oct 2025 11:32:46 +0200 Subject: [PATCH 1/3] feat(pagination): new segmentation primitive feat(pagination): jump pages feat(pagination): fix set page to non-existent page feat(pagination): fix demo Signed-off-by: Alex Lohr --- README.md | 2 +- packages/pagination/README.md | 21 ++++ packages/pagination/dev/index.tsx | 50 ++++---- packages/pagination/package.json | 1 + packages/pagination/src/index.ts | 102 ++++++++++++++-- packages/pagination/test/index.test.ts | 110 +++++++++++++++++- .../primitives/DocumentHydrationHelper.tsx | 2 +- site/src/routes/playground/playground.scss | 3 + 8 files changed, 259 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 913478134..5cef66d27 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ The goal of Solid Primitives is to wrap client and server side functionality to |

*UI Patterns*

| |[marker](https://github.com/solidjs-community/solid-primitives/tree/main/packages/marker#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-1.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createMarker](https://github.com/solidjs-community/solid-primitives/tree/main/packages/marker#createmarker)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/marker?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/marker)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/marker?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/marker)| |[masonry](https://github.com/solidjs-community/solid-primitives/tree/main/packages/masonry#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createMasonry](https://github.com/solidjs-community/solid-primitives/tree/main/packages/masonry#createmasonry)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/masonry?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/masonry)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/masonry?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/masonry)| -|[pagination](https://github.com/solidjs-community/solid-primitives/tree/main/packages/pagination#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createPagination](https://github.com/solidjs-community/solid-primitives/tree/main/packages/pagination#createpagination)
[createInfiniteScroll](https://github.com/solidjs-community/solid-primitives/tree/main/packages/pagination#createinfinitescroll)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/pagination?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/pagination)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/pagination?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/pagination)| +|[pagination](https://github.com/solidjs-community/solid-primitives/tree/main/packages/pagination#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createPagination](https://github.com/solidjs-community/solid-primitives/tree/main/packages/pagination#createpagination)
[createSegment](https://github.com/solidjs-community/solid-primitives/tree/main/packages/pagination#createsegment)
[createInfiniteScroll](https://github.com/solidjs-community/solid-primitives/tree/main/packages/pagination#createinfinitescroll)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/pagination?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/pagination)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/pagination?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/pagination)| |[virtual](https://github.com/solidjs-community/solid-primitives/tree/main/packages/virtual#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createVirutalList](https://github.com/solidjs-community/solid-primitives/tree/main/packages/virtual#createvirutallist)
[VirtualList](https://github.com/solidjs-community/solid-primitives/tree/main/packages/virtual#virtuallist)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/virtual?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/virtual)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/virtual?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/virtual)| |

*Animation*

| |[presence](https://github.com/solidjs-community/solid-primitives/tree/main/packages/presence#readme)|[![STAGE](https://img.shields.io/endpoint?style=for-the-badge&label=&url=https%3A%2F%2Fraw.githubusercontent.com%2Fsolidjs-community%2Fsolid-primitives%2Fmain%2Fassets%2Fbadges%2Fstage-0.json)](https://github.com/solidjs-community/solid-primitives/blob/main/CONTRIBUTING.md#contribution-process)|[createPresence](https://github.com/solidjs-community/solid-primitives/tree/main/packages/presence#createpresence)|[![SIZE](https://img.shields.io/bundlephobia/minzip/@solid-primitives/presence?style=for-the-badge&label=)](https://bundlephobia.com/package/@solid-primitives/presence)|[![VERSION](https://img.shields.io/npm/v/@solid-primitives/presence?style=for-the-badge&label=)](https://www.npmjs.com/package/@solid-primitives/presence)| diff --git a/packages/pagination/README.md b/packages/pagination/README.md index e4c0f546a..856e0ada8 100644 --- a/packages/pagination/README.md +++ b/packages/pagination/README.md @@ -11,6 +11,7 @@ A primitive that creates all the reactive data to manage your pagination: - [`createPagination`](#createpagination) - Provides an array with the properties to fill your pagination with and a page setter/getter. +- [`createSegment`](#createSegment) - Provides a reactive segment of an array (e.g. a page of a number of items). - [`createInfiniteScroll`](#createinfinitescroll) - Provides an easy way to implement infinite scrolling. ## Installation @@ -53,6 +54,8 @@ type PaginationOptions = { nextContent?: JSX.Element; /** content for the last page element, e.g. an SVG icon, default is ">|" */ lastContent?: JSX.Element; + /** number of pages a large jump, if it should exist, should skip */ + jumpPages?: number; }; // Returns a tuple of props, page and setPage. @@ -109,11 +112,29 @@ return ( - options for aria-labels - optional: touch controls +## `createSegment` + +It is a common requirement to put multiple items on a single page, which exactly is what `createSegment` is for. + +```tsx +const segment = createSegment(items, limit, page); + +return ( + {(item) => } +) +``` + +* `items` can be any array of items or an accessor with an array of items; even if the array increases in size, the segment will only change if the growth brings an actual change +* `limit` is the limit for the number of items within a segment; this can be a number or an accessor containing a number +* `page` is an accessor with the number or the segment page, starting with 1 + + ### Demo You may view a working example here: https://primitives.solidjs.community/playground/pagination/ + ## `createInfiniteScroll` Combines [`createResource`](https://www.solidjs.com/docs/latest/api#createresource) with [`IntersectionObserver`](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) to provide an easy way to implement infinite scrolling. diff --git a/packages/pagination/dev/index.tsx b/packages/pagination/dev/index.tsx index 0e19394db..c533962f5 100644 --- a/packages/pagination/dev/index.tsx +++ b/packages/pagination/dev/index.tsx @@ -1,6 +1,6 @@ -import { type Component, For, Show } from "solid-js"; +import { type Component, For, Show, createSignal } from "solid-js"; -import { createInfiniteScroll, createPagination } from "../src/index.js"; +import { createInfiniteScroll, createSegment, createPagination } from "../src/index.js"; async function fetcher(page: number) { let elements: string[] = []; @@ -14,37 +14,45 @@ async function fetcher(page: number) { return elements; } -const PaginationDemo: Component = () => { - const [paginationProps, page, setPage] = createPagination({ pages: 100 }); +const PaginationSegmentationDemo: Component = () => { + const items = Array.from({ length: 1000 }, (_, i) => i + 1); + const [limit, setLimit] = createSignal(10); + const [paginationProps, page, setPage] = createPagination(() => ({ + pages: Math.ceil(items.length / limit()), + maxPages: 8, + jumpPages: 10, + })); + const segment = createSegment(items, limit, page); return ( -
-
-

Pagination component

-

Current page: {page()} / 100

- - +
+

Pagination & Segmentation:

+ {(i) => (

{i}

)}
+ +
+ + + +
); -}; +} const InfiniteScrollDemo = () => { const [pages, infiniteScrollLoader, { end }] = createInfiniteScroll(fetcher); infiniteScrollLoader; return ( -
-
-

Infinite Scrolling:

+
+
+

Infinite Scrolling:

{item =>

{item}

}
-

Loading...

+ Loading...
@@ -53,8 +61,8 @@ const InfiniteScrollDemo = () => { const App: Component = () => { return ( -
- +
+
); diff --git a/packages/pagination/package.json b/packages/pagination/package.json index 90afff681..b3fdda53f 100644 --- a/packages/pagination/package.json +++ b/packages/pagination/package.json @@ -21,6 +21,7 @@ "stage": 0, "list": [ "createPagination", + "createSegment", "createInfiniteScroll" ], "category": "UI Patterns" diff --git a/packages/pagination/src/index.ts b/packages/pagination/src/index.ts index 8e2bc7bb6..5195675ba 100644 --- a/packages/pagination/src/index.ts +++ b/packages/pagination/src/index.ts @@ -9,9 +9,43 @@ import { createResource, createSignal, onCleanup, + untrack, } from "solid-js"; import { isServer } from "solid-js/web"; +/** + * createSegment - create a reactive segment out of an array of items + * @param {MaybeAccessor} items - array of items + * @param {MaybeAccessor} limit - limit of items per segment + * @param {Accessor} page - segment number starting with 1 + * + * ```ts + * const [limit, setLimit] = createSignal(10); + * const [paginationProps, page, setPage] = createPagination(() => ({ + * pages: Math.ceil(items().length / limit()) + * })); + * const segment = createSegment(items, limit, page); + * ``` + */ +export const createSegment = ( + items: MaybeAccessor, + limit: MaybeAccessor, + page: Accessor +): Accessor => { + let previousStart = NaN, previousEnd = NaN; + return createMemo((previous) => { + const currentItems = access(items); + const start = (page() - 1) * access(limit); + const end = Math.min(start + access(limit), currentItems.length); + if (previous && (previous.length === 0 && end <= start || start === previousStart && end === previousEnd)) { + return previous; + } + previousStart = start; + previousEnd = end; + return currentItems.slice(start, end); + }); +} + export type PaginationOptions = { /** the overall number of pages */ pages: number; @@ -35,6 +69,8 @@ export type PaginationOptions = { nextContent?: JSX.Element; /** content for the last page element, e.g. an SVG icon, default is ">|" */ lastContent?: JSX.Element; + /** number of pages a large jump, if it should exist, should skip */ + jumpPages?: number; }; export type PaginationProps = { @@ -98,22 +134,42 @@ export const createPagination = ( ): [props: Accessor, page: Accessor, setPage: Setter] => { const opts = createMemo(() => Object.assign({}, PAGINATION_DEFAULTS, access(options))); const [page, _setPage] = createSignal(opts().initialPage || 1); + + // do not allow pages beyond the number of pages in case the latter changes const setPage = (p: number | ((_p: number) => number)) => { if (typeof p === "function") { p = p(page()); } - return p >= 1 && p <= opts().pages ? _setPage(p) : page(); + if (p < 1) { + return _setPage(1); + } + const pages = opts().pages; + if (p > pages) { + return _setPage(pages); + } + return _setPage(p); }; + // normalize in case the number of pages changes, do not run the first time + createComputed((previous) => { + opts().pages; + return previous ? setPage(untrack(page)) : true; + }); + + const goPage = (p: number | ((p: number) => number), ev: KeyboardEvent) => { + setPage(p); + ev.currentTarget?.parentNode.querySelector('[aria-current="true"]').focus(); + } + const onKeyUp = (pageNo: number, ev: KeyboardEvent) => ( ({ - ArrowLeft: () => setPage(p => p - 1), - ArrowRight: () => setPage(p => p + 1), - Home: () => setPage(1), - End: () => setPage(opts().pages), - Space: () => setPage(pageNo), - Return: () => setPage(pageNo), + ArrowLeft: () => goPage(p => p - 1, ev), + ArrowRight: () => goPage(p => p + 1, ev), + Home: () => goPage(1, ev), + End: () => goPage(opts().pages, ev), + Space: () => goPage(pageNo, ev), + Return: () => goPage(pageNo, ev), })[ev.key] || noop )(); @@ -197,6 +253,32 @@ export const createPagination = ( page: { get: () => opts().pages, enumerable: false }, }, ); + const jumpBack = Object.defineProperties( + isServer + ? ({} as PaginationProps[number]) + : ({ + onClick: () => setPage(Math.max(1, page() - (opts().jumpPages || Infinity))), + onKeyUp: (ev: KeyboardEvent) => onKeyUp(page() - (opts().jumpPages || Infinity), ev), + } as unknown as PaginationProps[number]), + { + disabled: { get: () => page() - (opts().jumpPages || Infinity) < 0, set: noop, enumerable: true }, + children: { get: () => `-${opts().jumpPages}`, set: noop, enumerable: true }, + page: { get: () => Math.max(1, page() - (opts().jumpPages || Infinity)), enumerable: false }, + } + ); + const jumpForth = Object.defineProperties( + isServer + ? ({} as PaginationProps[number]) + : ({ + onClick: () => setPage(Math.min(opts().pages, page() + (opts().jumpPages || Infinity))), + onKeyUp: (ev: KeyboardEvent) => onKeyUp(Math.min(opts().pages, page() + (opts().jumpPages || Infinity)), ev), + } as unknown as PaginationProps[number]), + { + disabled: { get: () => page() + (opts().jumpPages || Infinity) > opts().pages, set: noop, enumerable: true }, + children: { get: () => `+${opts().jumpPages}`, set: noop, enumerable: true }, + page: { get: () => Math.min(opts().pages, page() + (opts().jumpPages || Infinity)), enumerable: false }, + } + ) const start = createMemo(() => Math.min(opts().pages - maxPages(), Math.max(1, page() - (maxPages() >> 1)) - 1), @@ -219,6 +301,9 @@ export const createPagination = ( if (showFirst()) { props.push(first); } + if (opts().jumpPages) { + props.push(jumpBack); + } if (showPrev()) { props.push(back); } @@ -226,6 +311,9 @@ export const createPagination = ( if (showNext()) { props.push(next); } + if (opts().jumpPages) { + props.push(jumpForth); + } if (showLast()) { props.push(last); } diff --git a/packages/pagination/test/index.test.ts b/packages/pagination/test/index.test.ts index 0486c9f40..fbd640478 100644 --- a/packages/pagination/test/index.test.ts +++ b/packages/pagination/test/index.test.ts @@ -1,6 +1,7 @@ import { describe, test, expect } from "vitest"; -import { createRoot, createSignal } from "solid-js"; -import { createInfiniteScroll, createPagination } from "../src/index.js"; +import { createMemo, createRoot, createSignal } from "solid-js"; +import { createInfiniteScroll, createPagination, createSegment, PaginationOptions } from "../src/index.js"; +import { testEffect } from "../../resource/test/index.test.js"; describe("createPagination", () => { test("createPagination returns page getter and setter", () => @@ -98,6 +99,111 @@ describe("createPagination", () => { dispose(); }); }); + + test("setting page below one will yield the first page", () => { + createRoot(dispose => { + const [paginationProps, page, setPage] = createPagination({ + pages: 10, + maxPages: 5 + }); + + expect(page()).toBe(1); + setPage(0); + expect(page()).toBe(1); + setPage(-1); + expect(page()).toBe(1); + + dispose(); + }) + }); + + test("setting page beyond the number pages will yield the last page", () => { + createRoot(dispose => { + const [paginationProps, page, setPage] = createPagination({ + pages: 10, + maxPages: 5, + initialPage: 10 + }); + + expect(page()).toBe(10); + setPage(11); + expect(page()).toBe(10); + setPage(Infinity); + expect(page()).toBe(10); + + dispose(); + }); + }); + + test("lowering the number of pages will not make the page go beyond it", () => { + createRoot(dispose => { + const [options, setOptions] = createSignal({ pages: 10, maxPages: 5, initialPage: 10 }); + const [paginationProps, page, setPage] = createPagination(options); + + expect(page()).toBe(10); + setOptions({ pages: 8, maxPages: 5 }); + expect(page()).toBe(8); + + dispose(); + }); + }); +}); + +describe("createSegment", () => { + test("creates valid segments", () => { + createRoot(dispose => { + const items = createMemo(() => Array.from({ length: 50 }, (_, i) => i + 1)); + const [page, setPage] = createSignal(1); + const segment = createSegment(items, 10, page); + + expect(segment()).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + setPage(5); + expect(segment()).toEqual([41, 42, 43, 44, 45, 46, 47, 48, 49, 50]); + setPage(6); + expect(segment()).toEqual([]); + + dispose(); + }); + }); + + test("does not create the same segment twice", () => { + createRoot(dispose => { + const [length, setLength] = createSignal(50); + const items = createMemo(() => Array.from({ length: length() }, (_, i) => i + 1)); + const [page, setPage] = createSignal(1); + const segment = createSegment(items, 10, page); + + const seg1 = segment(); + setLength(10); + const seg2 = segment(); + expect(seg1).toBe(seg2); + setPage(2); + const seg3 = segment(); + setPage(3); + const seg4 = segment(); + expect(seg3).toBe(seg4); + + dispose(); + }); + }); + + test("creates a new segment if new items are added", () => { + createRoot(dispose => { + const [length, setLength] = createSignal(55); + const items = createMemo(() => Array.from({ length: length() }, (_, i) => i + 1)); + const [page, setPage] = createSignal(6); + const segment = createSegment(items, 10, page); + + const seg1 = segment(); + expect(seg1).toEqual([51, 52, 53, 54, 55]); + setLength(57); + const seg2 = segment(); + expect(seg2).toEqual([51, 52, 53, 54, 55, 56, 57]); + expect(seg1).not.toBe(seg2); + + dispose(); + }); + }) }); //@ts-ignore diff --git a/site/src/primitives/DocumentHydrationHelper.tsx b/site/src/primitives/DocumentHydrationHelper.tsx index 49b5abda3..dd62b47ad 100644 --- a/site/src/primitives/DocumentHydrationHelper.tsx +++ b/site/src/primitives/DocumentHydrationHelper.tsx @@ -1,5 +1,5 @@ import { getRequestEvent, HydrationScript, NoHydration } from "solid-js/web"; -import { Asset, PageEvent } from "@solidjs/start/server"; +import type { Asset, PageEvent } from "@solidjs/start/server"; import type { JSX } from "solid-js"; const assetMap = { diff --git a/site/src/routes/playground/playground.scss b/site/src/routes/playground/playground.scss index 4d06ec720..372752dcb 100644 --- a/site/src/routes/playground/playground.scss +++ b/site/src/routes/playground/playground.scss @@ -18,6 +18,9 @@ Styles applied in the package playgrounds. button { @apply border-1 flex cursor-pointer select-none items-center justify-center rounded border-teal-500 bg-teal-600 p-3 py-2 font-semibold text-white hover:bg-teal-500 disabled:cursor-not-allowed disabled:bg-teal-700 disabled:saturate-50 disabled:hover:bg-teal-700; } + button[aria-current="true"] { + @apply bg-teal-300 text-gray-600; + } .wrapper-h { @apply flex items-center justify-center space-x-4 space-y-0 rounded-2xl bg-gray-700 p-6; From b9d71b47388260c88e36508092d4ca466486f695 Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Tue, 14 Oct 2025 11:34:17 +0200 Subject: [PATCH 2/3] chore: add changeset --- .changeset/beige-icons-visit.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/beige-icons-visit.md diff --git a/.changeset/beige-icons-visit.md b/.changeset/beige-icons-visit.md new file mode 100644 index 000000000..7fe353936 --- /dev/null +++ b/.changeset/beige-icons-visit.md @@ -0,0 +1,5 @@ +--- +"@solid-primitives/pagination": minor +--- + +new segmentation primitive, jump pages, fix set page to non-existent page, fix demo From 2a3f9c1a77f2430142b1f39019a00fa20b824ae5 Mon Sep 17 00:00:00 2001 From: Alex Lohr Date: Tue, 14 Oct 2025 11:58:15 +0200 Subject: [PATCH 3/3] fix: types --- packages/pagination/src/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/pagination/src/index.ts b/packages/pagination/src/index.ts index 5195675ba..d585eeefe 100644 --- a/packages/pagination/src/index.ts +++ b/packages/pagination/src/index.ts @@ -158,7 +158,8 @@ export const createPagination = ( const goPage = (p: number | ((p: number) => number), ev: KeyboardEvent) => { setPage(p); - ev.currentTarget?.parentNode.querySelector('[aria-current="true"]').focus(); + if ('currentTarget' in ev) + (ev.currentTarget as HTMLElement).parentNode?.querySelector('[aria-current="true"]')?.focus(); } const onKeyUp = (pageNo: number, ev: KeyboardEvent) =>