From 0e498abb3f59a947f87b3d554ee2fe4399ee5cec Mon Sep 17 00:00:00 2001
From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com>
Date: Fri, 24 Apr 2026 18:57:08 +0200
Subject: [PATCH 1/7] feat: add AppPagination named export for app router
Extract the presentational component into `src/pagination.tsx` and inject
a routing adapter from per-router wrappers:
- `PagesPagination` (default export) uses `next/router` + `next/head`
- `AppPagination` uses `next/navigation` and omits the ``
SEO hints (client components cannot write to `
`)
The default export keeps pointing at `PagesPagination` for backwards
compatibility. Both named exports ship from the single entry point.
Postbuild fixes for microbundle-crl:
- Prepend `'use client'` so Next.js treats the bundled module as a client
component (the directive is stripped during rollup bundling)
- Inject `__esModule` marker so CJS consumers resolve the default export
via `.default` instead of getting the whole exports object
Co-Authored-By: Claude Opus 4.7 (1M context)
---
package.json | 2 +-
scripts/add-use-client.js | 37 +++++++
src/adapters/app.tsx | 38 +++++++
src/adapters/pages.tsx | 24 ++++
src/index.test.ts | 8 +-
src/index.tsx | 218 +------------------------------------
src/pagination.tsx | 223 ++++++++++++++++++++++++++++++++++++++
7 files changed, 335 insertions(+), 215 deletions(-)
create mode 100644 scripts/add-use-client.js
create mode 100644 src/adapters/app.tsx
create mode 100644 src/adapters/pages.tsx
create mode 100644 src/pagination.tsx
diff --git a/package.json b/package.json
index c95f7bd..2a8145b 100644
--- a/package.json
+++ b/package.json
@@ -12,7 +12,7 @@
"node": "^20 || ^22 || ^24"
},
"scripts": {
- "build": "microbundle-crl --no-compress --format modern,cjs",
+ "build": "microbundle-crl --no-compress --format modern,cjs && node scripts/add-use-client.js",
"start": "microbundle-crl watch --no-compress --format modern,cjs",
"prepare": "run-s build",
"test": "run-s test:types test:unit",
diff --git a/scripts/add-use-client.js b/scripts/add-use-client.js
new file mode 100644
index 0000000..83c7763
--- /dev/null
+++ b/scripts/add-use-client.js
@@ -0,0 +1,37 @@
+const fs = require('fs')
+const path = require('path')
+
+const DIRECTIVE = "'use client';\n"
+const ESM_MARKER = "Object.defineProperty(exports, '__esModule', { value: true });\n"
+
+function prepend(file, snippet) {
+ const contents = fs.readFileSync(file, 'utf8')
+ if (contents.includes(snippet.trim())) return contents
+ const next = snippet + contents
+ fs.writeFileSync(file, next)
+ return next
+}
+
+for (const rel of ['dist/index.js', 'dist/index.modern.js']) {
+ const file = path.join(__dirname, '..', rel)
+ if (!fs.existsSync(file)) continue
+ let contents = fs.readFileSync(file, 'utf8')
+ if (!contents.startsWith("'use client'") && !contents.startsWith('"use client"')) {
+ contents = DIRECTIVE + contents
+ fs.writeFileSync(file, contents)
+ }
+}
+
+// CJS output needs the __esModule marker so `import X from 'pkg'` picks up
+// `.default` instead of the whole exports object when both default + named
+// exports exist. microbundle-crl doesn't emit it.
+const cjs = path.join(__dirname, '..', 'dist/index.js')
+if (fs.existsSync(cjs)) {
+ const contents = fs.readFileSync(cjs, 'utf8')
+ if (!contents.includes("__esModule")) {
+ const lines = contents.split('\n')
+ const insertAt = lines[0].startsWith("'use client'") || lines[0].startsWith('"use client"') ? 1 : 0
+ lines.splice(insertAt, 0, ESM_MARKER.trim())
+ fs.writeFileSync(cjs, lines.join('\n'))
+ }
+}
diff --git a/src/adapters/app.tsx b/src/adapters/app.tsx
new file mode 100644
index 0000000..a85360e
--- /dev/null
+++ b/src/adapters/app.tsx
@@ -0,0 +1,38 @@
+'use client'
+
+import React from 'react'
+import { useRouter, usePathname, useSearchParams } from 'next/navigation'
+
+import Pagination, { PaginationProps } from '../pagination'
+
+const AppPagination = (props: PaginationProps) => {
+ const router = useRouter()
+ const pathname = usePathname()
+ const searchParams = useSearchParams()
+
+ const query: Record = {}
+ if (searchParams) {
+ const seen: Record = {}
+ searchParams.forEach((_value, key) => {
+ if (seen[key]) return
+ seen[key] = true
+ const values = searchParams.getAll(key)
+ query[key] = values.length > 1 ? values : values[0]
+ })
+ }
+
+ return (
+ {
+ router.push(url)
+ }
+ }}
+ />
+ )
+}
+
+export default AppPagination
diff --git a/src/adapters/pages.tsx b/src/adapters/pages.tsx
new file mode 100644
index 0000000..bb59f10
--- /dev/null
+++ b/src/adapters/pages.tsx
@@ -0,0 +1,24 @@
+import React from 'react'
+import { useRouter } from 'next/router'
+import Head from 'next/head'
+
+import Pagination, { PaginationProps } from '../pagination'
+
+const PagesPagination = (props: PaginationProps) => {
+ const router = useRouter()
+ return (
+ {
+ router.push(url)
+ },
+ Head
+ }}
+ />
+ )
+}
+
+export default PagesPagination
diff --git a/src/index.test.ts b/src/index.test.ts
index e914fb3..e622bfa 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -1,4 +1,4 @@
-import Pagination from '.'
+import Pagination, { PagesPagination, AppPagination } from '.'
import { getSizes } from './lib/sizes'
import assert from 'assert'
@@ -6,6 +6,12 @@ describe('Pagination', () => {
it('is truthy', () => {
expect(Pagination).toBeTruthy()
})
+ it('default export is PagesPagination', () => {
+ expect(Pagination).toBe(PagesPagination)
+ })
+ it('exports AppPagination', () => {
+ expect(AppPagination).toBeTruthy()
+ })
})
// NOTE: [20,40,60,80,100] <= default sizes
diff --git a/src/index.tsx b/src/index.tsx
index 9f2f06c..fd6cfe6 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -1,215 +1,7 @@
-import React, { EventHandler, useEffect, useState } from 'react'
-import { useRouter } from 'next/router'
-import NextLink from 'next/link'
-import Head from 'next/head'
-import queryString from 'query-string'
-import pickBy from 'lodash/pickBy'
-import isEmpty from 'lodash/isEmpty'
+import PagesPagination from './adapters/pages'
-import List from './components/List'
-import Item from './components/Item'
-import Link from './components/Link'
-import Icon from './components/Icon'
-import Select from './components/Select'
+export { default as PagesPagination } from './adapters/pages'
+export { default as AppPagination } from './adapters/app'
+export type { PaginationProps } from './pagination'
-import { getSizes } from './lib/sizes'
-import { getPageNumbers } from './lib/get-page-numbers'
-
-import defaultTheme from './index.module.css'
-
-interface PaginationProps {
- /**
- * The total number of pages
- */
- total: number
- /**
- * A CSS modules style object
- */
- theme?: { [key: string]: any }
- /**
- * An array of page size numbers
- */
- sizes?: number[]
- /**
- * Label for the page size dropdown
- */
- perPageText?: string
- /**
- * Label for the invisible page size button
- */
- setPageSizeText?: string
- /**
- * Extra props to pass to the link component
- */
- linkProps?: { [key: string]: any }
- /**
- * Component used to render navigation links. Defaults to next/link.
- * Must accept `href` and a single anchor child (legacy-behaviour style).
- */
- linkComponent?: React.ComponentType
-}
-
-const Pagination = ({
- total,
- theme,
- sizes,
- perPageText,
- setPageSizeText,
- linkProps,
- linkComponent: LinkComponent = NextLink
-}: PaginationProps) => {
- const styles = theme || defaultTheme
- const router = useRouter()
- const [hasRouter, setHasRouter] = useState(false)
- useEffect(() => {
- setHasRouter(true)
- }, [router])
-
- if (!hasRouter) return null
- const query = pickBy({ ...(router.query || {}) }, (q) => !isEmpty(q))
- const currentPage = Number(query.page || 1)
- // default|custom => evaluated sizes
- const cSizes = getSizes(sizes)
- const pageSize = Number(query.size) || cSizes[0]
- const isLastPage = currentPage * pageSize >= total
- const pageNumbers = getPageNumbers({ currentPage, pageSize, total })
-
- const path = router.pathname
-
- const url = (page: number | string) =>
- `?${queryString.stringify({
- ...query,
- page
- })}`
-
- return (
-
- )
-}
-
-Pagination.defaultProps = {
- total: 0,
- perPageText: 'per page',
- setPageSizeText: 'Set page size',
- sizes: undefined,
- linkProps: {},
- linkComponent: undefined
-}
-
-export default Pagination
+export default PagesPagination
diff --git a/src/pagination.tsx b/src/pagination.tsx
new file mode 100644
index 0000000..70592a8
--- /dev/null
+++ b/src/pagination.tsx
@@ -0,0 +1,223 @@
+import React, { useEffect, useState } from 'react'
+import NextLink from 'next/link'
+import queryString from 'query-string'
+import pickBy from 'lodash/pickBy'
+import isEmpty from 'lodash/isEmpty'
+
+import List from './components/List'
+import Item from './components/Item'
+import Link from './components/Link'
+import Icon from './components/Icon'
+import Select from './components/Select'
+
+import { getSizes } from './lib/sizes'
+import { getPageNumbers } from './lib/get-page-numbers'
+
+import defaultTheme from './index.module.css'
+
+export interface PaginationProps {
+ /**
+ * The total number of pages
+ */
+ total: number
+ /**
+ * A CSS modules style object
+ */
+ theme?: { [key: string]: any }
+ /**
+ * An array of page size numbers
+ */
+ sizes?: number[]
+ /**
+ * Label for the page size dropdown
+ */
+ perPageText?: string
+ /**
+ * Label for the invisible page size button
+ */
+ setPageSizeText?: string
+ /**
+ * Extra props to pass to the link component
+ */
+ linkProps?: { [key: string]: any }
+ /**
+ * Component used to render navigation links. Defaults to next/link.
+ * Must accept `href` and a single anchor child (legacy-behaviour style).
+ */
+ linkComponent?: React.ComponentType
+}
+
+export interface RoutingAdapter {
+ pathname: string
+ query: Record
+ push: (url: string) => void
+ /**
+ * Optional component used to inject SEO `` into the
+ * document head. Pages router passes `next/head`; app router omits it
+ * because client components cannot write to ``.
+ */
+ Head?: React.ComponentType<{ children: React.ReactNode }>
+}
+
+interface InternalProps extends PaginationProps {
+ routing: RoutingAdapter
+}
+
+const Pagination = ({
+ total,
+ theme,
+ sizes,
+ perPageText = 'per page',
+ setPageSizeText = 'Set page size',
+ linkProps = {},
+ linkComponent: LinkComponent = NextLink,
+ routing
+}: InternalProps) => {
+ const styles = theme || defaultTheme
+ const [mounted, setMounted] = useState(false)
+ useEffect(() => {
+ setMounted(true)
+ }, [])
+
+ if (!mounted) return null
+ const query = pickBy({ ...routing.query }, (q) => !isEmpty(q))
+ const currentPage = Number(query.page || 1)
+ const cSizes = getSizes(sizes)
+ const pageSize = Number(query.size) || cSizes[0]
+ const isLastPage = currentPage * pageSize >= total
+ const pageNumbers = getPageNumbers({ currentPage, pageSize, total })
+
+ const path = routing.pathname
+
+ const url = (page: number | string) =>
+ `?${queryString.stringify({
+ ...query,
+ page
+ })}`
+
+ const Head = routing.Head
+
+ return (
+
+ )
+}
+
+export default Pagination
From faa8031a30658e5c78fc6318f75d4ad2ecf873e0 Mon Sep 17 00:00:00 2001
From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com>
Date: Fri, 24 Apr 2026 18:57:15 +0200
Subject: [PATCH 2/7] docs(example): demo AppPagination under an app router
route
Add an `app/` tree alongside the existing `pages/` directory so the demo
covers both routers:
- `app/layout.jsx` root layout
- `app/app-example/page.jsx` client component rendering `AppPagination`
inside `` (required for static export with useSearchParams)
- link to `/app-example` from the pages index
Switch the build to `output: 'export'` since `next export` no longer
supports the app router, and rename `out` back to `build` so the
`gh-pages` deploy keeps working.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
example/app/app-example/page.jsx | 30 ++++++++++++++++++++++++++++++
example/app/layout.jsx | 16 ++++++++++++++++
example/next.config.js | 1 +
example/package.json | 2 +-
example/pages/index.js | 3 +++
5 files changed, 51 insertions(+), 1 deletion(-)
create mode 100644 example/app/app-example/page.jsx
create mode 100644 example/app/layout.jsx
diff --git a/example/app/app-example/page.jsx b/example/app/app-example/page.jsx
new file mode 100644
index 0000000..5dd32ea
--- /dev/null
+++ b/example/app/app-example/page.jsx
@@ -0,0 +1,30 @@
+'use client'
+
+import React, { Suspense } from 'react'
+import { AppPagination } from '@etchteam/next-pagination/dist'
+
+import theme from '../../styles/theme.module.scss'
+
+export default function AppExample() {
+ return (
+
+ App Router Pagination
+
+ This page uses the AppPagination named export, which reads
+ the route and search params from next/navigation instead of{' '}
+ next/router.
+
+
+
+ Default theme
+
+
+ Custom theme
+
+
+ Custom page sizes
+
+
+
+ )
+}
diff --git a/example/app/layout.jsx b/example/app/layout.jsx
new file mode 100644
index 0000000..81f9cc9
--- /dev/null
+++ b/example/app/layout.jsx
@@ -0,0 +1,16 @@
+import React from 'react'
+
+import '@etchteam/next-pagination/dist/index.css'
+import '../styles/main.css'
+
+export const metadata = {
+ title: 'Next pagination (App Router)'
+}
+
+export default function RootLayout({ children }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/example/next.config.js b/example/next.config.js
index c382bd9..9f80c45 100644
--- a/example/next.config.js
+++ b/example/next.config.js
@@ -4,6 +4,7 @@ const withSourceMaps = require('@zeit/next-source-maps')()
const isProd = (process.env.NODE_ENV || 'production') === 'production'
module.exports = withSourceMaps({
+ output: 'export',
assetPrefix: isProd ? '/next-pagination' : undefined,
basePath: isProd ? '/next-pagination' : undefined
})
diff --git a/example/package.json b/example/package.json
index 953c99a..b0db5f1 100644
--- a/example/package.json
+++ b/example/package.json
@@ -13,7 +13,7 @@
},
"scripts": {
"dev": "next",
- "build": "next build && next export -o build",
+ "build": "next build && rm -rf build && mv out build",
"start": "next start"
},
"browserslist": [
diff --git a/example/pages/index.js b/example/pages/index.js
index 73b1fae..62a41f9 100644
--- a/example/pages/index.js
+++ b/example/pages/index.js
@@ -62,6 +62,9 @@ export default function Home() {
Dynamic pagination
+
+ App Router pagination
+
)
}
From 32be4a13e141a827fd45dd58bd01f0d74cfddcf9 Mon Sep 17 00:00:00 2001
From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com>
Date: Fri, 24 Apr 2026 18:59:07 +0200
Subject: [PATCH 3/7] test: add playwright e2e suite covering both routers
Boots the example dev server via playwright's webServer and exercises the
rendered pagination for the pages router (`/`) and the app router
(`/app-example`): first-page state, clicking a page number updates the
URL and current marker, and the page-size select resets to page 1 with
the new size.
Run locally with `npm run test:e2e`. Chromium only; CI wiring is not
included in this commit.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.gitignore | 6 +++
package-lock.json | 74 ++++++++++++++++++++++++++++++++++
package.json | 6 ++-
playwright.config.ts | 23 +++++++++++
tests/e2e/app-router.spec.ts | 28 +++++++++++++
tests/e2e/pages-router.spec.ts | 35 ++++++++++++++++
6 files changed, 170 insertions(+), 2 deletions(-)
create mode 100644 playwright.config.ts
create mode 100644 tests/e2e/app-router.spec.ts
create mode 100644 tests/e2e/pages-router.spec.ts
diff --git a/.gitignore b/.gitignore
index e502a14..872d66b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -22,3 +22,9 @@ yarn-debug.log*
yarn-error.log*
.next
+
+# playwright
+test-results
+playwright-report
+blob-report
+.playwright
diff --git a/package-lock.json b/package-lock.json
index 79d76c4..d1d1e09 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,6 +14,7 @@
"query-string": "6.12.1"
},
"devDependencies": {
+ "@playwright/test": "1.59.1",
"@types/jest": "^27.5.0",
"@types/lodash": "^4.14.182",
"@types/node": "^17.0.31",
@@ -3564,6 +3565,22 @@
"node": ">= 8"
}
},
+ "node_modules/@playwright/test": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz",
@@ -15578,6 +15595,38 @@
"node": ">=4"
}
},
+ "node_modules/playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.59.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
"node_modules/postcss": {
"version": "7.0.39",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz",
@@ -24945,6 +24994,15 @@
"fastq": "^1.6.0"
}
},
+ "@playwright/test": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
+ "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
+ "dev": true,
+ "requires": {
+ "playwright": "1.59.1"
+ }
+ },
"@pmmmwh/react-refresh-webpack-plugin": {
"version": "0.5.10",
"resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.10.tgz",
@@ -33938,6 +33996,22 @@
}
}
},
+ "playwright": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+ "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+ "dev": true,
+ "requires": {
+ "fsevents": "2.3.2",
+ "playwright-core": "1.59.1"
+ }
+ },
+ "playwright-core": {
+ "version": "1.59.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+ "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+ "dev": true
+ },
"postcss": {
"version": "7.0.39",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-7.0.39.tgz",
diff --git a/package.json b/package.json
index 2a8145b..46e21b8 100644
--- a/package.json
+++ b/package.json
@@ -19,12 +19,13 @@
"test:unit": "cross-env CI=1 react-scripts test --env=jsdom",
"test:watch": "react-scripts test --env=jsdom",
"test:types": "tsc",
+ "test:e2e": "playwright test",
"predeploy": "cd example && npm install && npm run build && touch build/.nojekyll",
"deploy": "gh-pages -t -d example/build"
},
"peerDependencies": {
- "react": ">=16",
- "next": ">=10"
+ "next": ">=10",
+ "react": ">=16"
},
"dependencies": {
"classnames": "^2.3.1",
@@ -32,6 +33,7 @@
"query-string": "6.12.1"
},
"devDependencies": {
+ "@playwright/test": "1.59.1",
"@types/jest": "^27.5.0",
"@types/lodash": "^4.14.182",
"@types/node": "^17.0.31",
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..5b4e1f1
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,23 @@
+import { defineConfig, devices } from '@playwright/test'
+
+const PORT = 3000
+const BASE_URL = `http://localhost:${PORT}`
+
+export default defineConfig({
+ testDir: 'tests/e2e',
+ fullyParallel: true,
+ forbidOnly: !!process.env.CI,
+ retries: process.env.CI ? 2 : 0,
+ reporter: process.env.CI ? 'github' : 'list',
+ use: {
+ baseURL: BASE_URL,
+ trace: 'on-first-retry'
+ },
+ projects: [{ name: 'chromium', use: devices['Desktop Chrome'] }],
+ webServer: {
+ command: 'npm --prefix example run dev',
+ url: BASE_URL,
+ reuseExistingServer: !process.env.CI,
+ timeout: 120_000
+ }
+})
diff --git a/tests/e2e/app-router.spec.ts b/tests/e2e/app-router.spec.ts
new file mode 100644
index 0000000..673e2fa
--- /dev/null
+++ b/tests/e2e/app-router.spec.ts
@@ -0,0 +1,28 @@
+import { expect, test } from '@playwright/test'
+
+test.describe('app router', () => {
+ test('renders the first page with previous disabled', async ({ page }) => {
+ await page.goto('/app-example')
+ const nav = page.getByRole('navigation', { name: 'pagination' }).first()
+ await expect(nav).toBeVisible()
+ await expect(nav.getByLabel('Page 1, current page')).toBeVisible()
+ await expect(nav.getByLabel('No previous page available')).toBeVisible()
+ })
+
+ test('clicking a page number updates the URL and current page', async ({ page }) => {
+ await page.goto('/app-example')
+ const nav = page.getByRole('navigation', { name: 'pagination' }).first()
+ await nav.getByLabel('Page 2', { exact: true }).click()
+ await expect(page).toHaveURL(/\?page=2/)
+ await expect(nav.getByLabel('Page 2, current page')).toBeVisible()
+ await expect(nav.getByLabel('Previous page')).toBeVisible()
+ })
+
+ test('page size selector pushes size + resets to page 1', async ({ page }) => {
+ await page.goto('/app-example?page=3')
+ const nav = page.getByRole('navigation', { name: 'pagination' }).first()
+ await nav.getByLabel('per page').selectOption('40')
+ await expect(page).toHaveURL(/size=40/)
+ await expect(page).toHaveURL(/page=1/)
+ })
+})
diff --git a/tests/e2e/pages-router.spec.ts b/tests/e2e/pages-router.spec.ts
new file mode 100644
index 0000000..ef9e622
--- /dev/null
+++ b/tests/e2e/pages-router.spec.ts
@@ -0,0 +1,35 @@
+import { expect, test } from '@playwright/test'
+
+test.describe('pages router', () => {
+ test('renders the first page with previous disabled', async ({ page }) => {
+ await page.goto('/')
+ const nav = page.getByRole('navigation', { name: 'pagination' }).first()
+ await expect(nav).toBeVisible()
+ await expect(nav.getByLabel('Page 1, current page')).toBeVisible()
+ await expect(nav.getByLabel('No previous page available')).toBeVisible()
+ })
+
+ test('clicking a page number updates the URL and current page', async ({ page }) => {
+ await page.goto('/')
+ const nav = page.getByRole('navigation', { name: 'pagination' }).first()
+ await nav.getByLabel('Page 2', { exact: true }).click()
+ await expect(page).toHaveURL(/\?page=2/)
+ await expect(nav.getByLabel('Page 2, current page')).toBeVisible()
+ await expect(nav.getByLabel('Previous page')).toBeVisible()
+ })
+
+ test('page size selector pushes size + resets to page 1', async ({ page }) => {
+ await page.goto('/?page=3')
+ const nav = page.getByRole('navigation', { name: 'pagination' }).first()
+ await nav.getByLabel('per page').selectOption('40')
+ await expect(page).toHaveURL(/size=40/)
+ await expect(page).toHaveURL(/page=1/)
+ })
+
+ test('links to the app router example', async ({ page }) => {
+ await page.goto('/')
+ await page.getByRole('link', { name: 'App Router pagination' }).click()
+ await expect(page).toHaveURL('/app-example')
+ await expect(page.getByRole('heading', { name: 'App Router Pagination' })).toBeVisible()
+ })
+})
From 250b9a4e62123e1a4e1e006abd34d28176762fc8 Mon Sep 17 00:00:00 2001
From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com>
Date: Fri, 24 Apr 2026 19:18:49 +0200
Subject: [PATCH 4/7] ci: run playwright e2e suite on pull requests
Build the lib, install the example and chromium, then run
`npm run test:e2e`. Upload the playwright report as an artifact so
failures can be inspected.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.github/workflows/ci.yml | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a631e8a..c8857fe 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -30,6 +30,26 @@ jobs:
cache: npm
- run: npm ci
- run: npm test
+ e2e:
+ name: 🎠E2E
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version-file: .nvmrc
+ cache: npm
+ - run: npm ci
+ - run: npm run build
+ - run: npm --prefix example ci
+ - run: npx playwright install --with-deps chromium
+ - run: npm run test:e2e
+ - uses: actions/upload-artifact@v4
+ if: ${{ !cancelled() }}
+ with:
+ name: playwright-report
+ path: playwright-report
+ retention-days: 7
lint:
name: 🧹 Lint
runs-on: ubuntu-latest
From b148f11b647a4d9abd4ebc59a262730d1f531a34 Mon Sep 17 00:00:00 2001
From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com>
Date: Mon, 27 Apr 2026 11:14:53 +0200
Subject: [PATCH 5/7] refactor: address PR #140 review feedback
- src/adapters/app.tsx: simplify searchParams dedup with Set.forEach
instead of an ad-hoc seen map.
- scripts/add-use-client.js: collapse the two passes into a single
prefix-then-write helper so the directive and __esModule marker can
no longer interleave incorrectly. The CJS bundle gets both prefixes;
the ESM bundle only needs the directive.
- playwright + example: run the e2e suite against the production bundle
(`next build` + `next start`) when CI is set, matching what users
ship. Locally we keep `next dev` for fast iteration. The deploy build
now gates `output: 'export'` and basePath/assetPrefix on
EXAMPLE_DEPLOY=true so the test build uses plain server output.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
example/next.config.js | 8 +++----
example/package.json | 3 ++-
playwright.config.ts | 8 +++++--
scripts/add-use-client.js | 46 +++++++++++++++------------------------
src/adapters/app.tsx | 7 +++---
5 files changed, 33 insertions(+), 39 deletions(-)
diff --git a/example/next.config.js b/example/next.config.js
index 9f80c45..63c1c54 100644
--- a/example/next.config.js
+++ b/example/next.config.js
@@ -1,10 +1,10 @@
/* eslint-disable @typescript-eslint/no-var-requires */
const withSourceMaps = require('@zeit/next-source-maps')()
-const isProd = (process.env.NODE_ENV || 'production') === 'production'
+const isDeploy = process.env.EXAMPLE_DEPLOY === 'true'
module.exports = withSourceMaps({
- output: 'export',
- assetPrefix: isProd ? '/next-pagination' : undefined,
- basePath: isProd ? '/next-pagination' : undefined
+ output: isDeploy ? 'export' : undefined,
+ assetPrefix: isDeploy ? '/next-pagination' : undefined,
+ basePath: isDeploy ? '/next-pagination' : undefined
})
diff --git a/example/package.json b/example/package.json
index b0db5f1..81a52d9 100644
--- a/example/package.json
+++ b/example/package.json
@@ -13,7 +13,8 @@
},
"scripts": {
"dev": "next",
- "build": "next build && rm -rf build && mv out build",
+ "build": "EXAMPLE_DEPLOY=true next build && rm -rf build && mv out build",
+ "build:server": "next build",
"start": "next start"
},
"browserslist": [
diff --git a/playwright.config.ts b/playwright.config.ts
index 5b4e1f1..171b80d 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -15,9 +15,13 @@ export default defineConfig({
},
projects: [{ name: 'chromium', use: devices['Desktop Chrome'] }],
webServer: {
- command: 'npm --prefix example run dev',
+ // CI runs the production bundle (build + start) so the suite exercises
+ // the same output users ship; local dev keeps the fast `next dev` loop.
+ command: process.env.CI
+ ? 'npm --prefix example run build:server && npm --prefix example run start'
+ : 'npm --prefix example run dev',
url: BASE_URL,
reuseExistingServer: !process.env.CI,
- timeout: 120_000
+ timeout: 180_000
}
})
diff --git a/scripts/add-use-client.js b/scripts/add-use-client.js
index 83c7763..aa4df50 100644
--- a/scripts/add-use-client.js
+++ b/scripts/add-use-client.js
@@ -4,34 +4,24 @@ const path = require('path')
const DIRECTIVE = "'use client';\n"
const ESM_MARKER = "Object.defineProperty(exports, '__esModule', { value: true });\n"
-function prepend(file, snippet) {
+function patch(file, { addDirective, addEsModule }) {
+ if (!fs.existsSync(file)) return
const contents = fs.readFileSync(file, 'utf8')
- if (contents.includes(snippet.trim())) return contents
- const next = snippet + contents
- fs.writeFileSync(file, next)
- return next
+ const hasDirective =
+ contents.startsWith("'use client'") || contents.startsWith('"use client"')
+ const hasEsModule = contents.includes('__esModule')
+ let prefix = ''
+ if (addDirective && !hasDirective) prefix += DIRECTIVE
+ if (addEsModule && !hasEsModule) prefix += ESM_MARKER
+ if (!prefix) return
+ fs.writeFileSync(file, prefix + contents)
}
-for (const rel of ['dist/index.js', 'dist/index.modern.js']) {
- const file = path.join(__dirname, '..', rel)
- if (!fs.existsSync(file)) continue
- let contents = fs.readFileSync(file, 'utf8')
- if (!contents.startsWith("'use client'") && !contents.startsWith('"use client"')) {
- contents = DIRECTIVE + contents
- fs.writeFileSync(file, contents)
- }
-}
-
-// CJS output needs the __esModule marker so `import X from 'pkg'` picks up
-// `.default` instead of the whole exports object when both default + named
-// exports exist. microbundle-crl doesn't emit it.
-const cjs = path.join(__dirname, '..', 'dist/index.js')
-if (fs.existsSync(cjs)) {
- const contents = fs.readFileSync(cjs, 'utf8')
- if (!contents.includes("__esModule")) {
- const lines = contents.split('\n')
- const insertAt = lines[0].startsWith("'use client'") || lines[0].startsWith('"use client"') ? 1 : 0
- lines.splice(insertAt, 0, ESM_MARKER.trim())
- fs.writeFileSync(cjs, lines.join('\n'))
- }
-}
+const root = path.join(__dirname, '..')
+// CJS bundle needs both: 'use client' for Next.js to treat the module as a
+// client component (microbundle strips the source directive), and the
+// __esModule marker so consumers' default-import interop resolves
+// `exports.default` instead of the whole exports object.
+patch(path.join(root, 'dist/index.js'), { addDirective: true, addEsModule: true })
+// ESM bundle only needs the directive; named/default exports work natively.
+patch(path.join(root, 'dist/index.modern.js'), { addDirective: true, addEsModule: false })
diff --git a/src/adapters/app.tsx b/src/adapters/app.tsx
index a85360e..ca9a401 100644
--- a/src/adapters/app.tsx
+++ b/src/adapters/app.tsx
@@ -12,10 +12,9 @@ const AppPagination = (props: PaginationProps) => {
const query: Record = {}
if (searchParams) {
- const seen: Record = {}
- searchParams.forEach((_value, key) => {
- if (seen[key]) return
- seen[key] = true
+ const keys = new Set()
+ searchParams.forEach((_value, key) => keys.add(key))
+ keys.forEach((key) => {
const values = searchParams.getAll(key)
query[key] = values.length > 1 ? values : values[0]
})
From 5a0cde61fad74d50c90072f67a268a4d556169e1 Mon Sep 17 00:00:00 2001
From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com>
Date: Mon, 27 Apr 2026 12:30:30 +0200
Subject: [PATCH 6/7] fix: address critical review feedback on app-router PR
Bundle split via subpath exports (/app, /pages) so app-router consumers
no longer pull in next/router and pages-router consumers can opt out of
the 'use client' directive. Root entry keeps both adapters for
back-compat.
Replace add-use-client.js with postbuild.js: handles multi-entry rename,
d.ts flatten, stale cleanup, per-entry directive patching. Throws on
missing dist instead of silently no-opping.
Clamp invalid ?page=/?size=/total values silently in the shared core
(parseInt + Number.isFinite + Math.min, handles string[] query values
from the app adapter).
Guard null searchParams/pathname in AppPagination with a dev warning,
replacing the silent ?? '' fallback that masked missing
wrappers.
Force CI to re-pack the example's file:.. dep on every run by switching
from npm ci to npm install --no-package-lock.
Restore unit coverage with src/pagination.test.tsx (core behaviour via
stub adapter) and src/adapters/app.test.tsx (mocked next/navigation).
Document AppPagination, the entrypoint table, the
requirement, and clamping behaviour in the README.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.github/workflows/ci.yml | 4 +-
README.md | 57 +++--
example/app/app-example/page.jsx | 2 +-
example/pages/[dynamic].js | 2 +-
example/pages/index.js | 2 +-
package-lock.json | 420 +++++++++++++++++++++++++++++++
package.json | 25 +-
scripts/add-use-client.js | 27 --
scripts/postbuild.js | 125 +++++++++
src/adapters/app.test.tsx | 98 ++++++++
src/adapters/app.tsx | 28 ++-
src/app.tsx | 6 +
src/index.test.ts | 7 +-
src/pages.tsx | 6 +
src/pagination.test.tsx | 274 ++++++++++++++++++++
src/pagination.tsx | 25 +-
16 files changed, 1043 insertions(+), 65 deletions(-)
delete mode 100644 scripts/add-use-client.js
create mode 100644 scripts/postbuild.js
create mode 100644 src/adapters/app.test.tsx
create mode 100644 src/app.tsx
create mode 100644 src/pages.tsx
create mode 100644 src/pagination.test.tsx
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c8857fe..829a199 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -41,7 +41,9 @@ jobs:
cache: npm
- run: npm ci
- run: npm run build
- - run: npm --prefix example ci
+ # `file:..` deps can be served from npm's cache; `install --no-package-lock`
+ # forces npm to re-pack the freshly built dist/ on every CI run instead.
+ - run: npm --prefix example install --no-package-lock
- run: npx playwright install --with-deps chromium
- run: npm run test:e2e
- uses: actions/upload-artifact@v4
diff --git a/README.md b/README.md
index 2e11b21..6e88ccc 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ TL;DR Just show me the [DEMO](https://etchteam.github.io/next-pagination)
- **Responsive.** Works on all devices.
- **Themeable.** Make it look however you want.
- **Self contained.** There's only one required prop to get going. The rest of the logic is handled for you.
-- **Works with Next.** Integrated with the Next.js router.
+- **Works with Next.** Supports both the pages router and the app router.
## Install
@@ -26,19 +26,15 @@ npm install --save @etchteam/next-pagination
## Usage
This component is fairly self contained. You will need to pass the **total number of potential results** in order to calculate the number of pages to show.
-```jsx
-import React, { Component } from 'react'
-
-import Pagination from '@etchteam/next-pagination'
+The package ships three entrypoints:
-class Example extends Component {
- render() {
- return
- }
-}
-```
+| Import path | Export(s) | Use when |
+| ---------------------------------------- | ----------------------------------------------- | ------------------------------ |
+| `@etchteam/next-pagination` | default `PagesPagination`, named `PagesPagination` + `AppPagination` | Either router (back-compat). |
+| `@etchteam/next-pagination/pages` | default `PagesPagination` | Pages router only — avoids pulling in `next/navigation` and the `'use client'` directive. |
+| `@etchteam/next-pagination/app` | default `AppPagination` | App router only — avoids pulling in `next/router` / `next/head`. |
-You will need to import the CSS, either in your `_app.js`, or in your Sass build.
+You will need to import the CSS, either in your `_app.js`, in your app router root layout, or in your Sass build.
```jsx
import '@etchteam/next-pagination/dist/index.css'
@@ -49,12 +45,45 @@ When used, the pagination component will reload the same route with added pagina
- `page` for the page number the user is on.
- `size` for the number of results per page.
-e.g. ?page=4&size=20
+e.g. `?page=4&size=20`
-The **default page** is 1. The **default size** is 20.
+The **default page** is 1. The **default size** is 20. Invalid values in the URL (`?page=abc`, `?page=-1`, `?page=99999`) are clamped to the valid range.
You'll need to load the actual data from your API yourself. We're only here for the front-end!
+### Pages router
+
+```jsx
+import Pagination from '@etchteam/next-pagination/pages'
+
+export default function Example() {
+ return
+}
+```
+
+The pages-router adapter also injects `` SEO hints into `` via `next/head`.
+
+### App router
+
+`AppPagination` reads the route via `next/navigation` and must be rendered inside a `` boundary (a Next.js requirement of `useSearchParams`).
+
+```jsx
+'use client'
+
+import { Suspense } from 'react'
+import { AppPagination } from '@etchteam/next-pagination/app'
+
+export default function Example() {
+ return (
+
+
+
+ )
+}
+```
+
+`AppPagination` does **not** emit `` SEO hints — `next/head` is pages-router only and Next's metadata APIs are server-only, so prev/next links can't be written from a client component. If you need them, render them server-side from your route's `generateMetadata`.
+
## Props
| Name | Type | Description |
diff --git a/example/app/app-example/page.jsx b/example/app/app-example/page.jsx
index 5dd32ea..ff54990 100644
--- a/example/app/app-example/page.jsx
+++ b/example/app/app-example/page.jsx
@@ -1,7 +1,7 @@
'use client'
import React, { Suspense } from 'react'
-import { AppPagination } from '@etchteam/next-pagination/dist'
+import { AppPagination } from '@etchteam/next-pagination/app'
import theme from '../../styles/theme.module.scss'
diff --git a/example/pages/[dynamic].js b/example/pages/[dynamic].js
index 9b17522..013935a 100644
--- a/example/pages/[dynamic].js
+++ b/example/pages/[dynamic].js
@@ -1,5 +1,5 @@
import React from 'react'
-import Pagination from '@etchteam/next-pagination/dist'
+import Pagination from '@etchteam/next-pagination/pages'
export default function Dynamic() {
return (
diff --git a/example/pages/index.js b/example/pages/index.js
index 62a41f9..c13beb5 100644
--- a/example/pages/index.js
+++ b/example/pages/index.js
@@ -1,7 +1,7 @@
import React from 'react'
import Link from 'next/link'
-import Pagination from '@etchteam/next-pagination/dist'
+import Pagination from '@etchteam/next-pagination/pages'
import theme from '../styles/theme.module.scss'
diff --git a/package-lock.json b/package-lock.json
index d1d1e09..70dba18 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -15,6 +15,8 @@
},
"devDependencies": {
"@playwright/test": "1.59.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^14.3.1",
"@types/jest": "^27.5.0",
"@types/lodash": "^4.14.182",
"@types/node": "^17.0.31",
@@ -46,6 +48,13 @@
"react": ">=16"
}
},
+ "node_modules/@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@@ -4073,6 +4082,155 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true
},
+ "node_modules/@testing-library/dom": {
+ "version": "9.3.4",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
+ "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.1.3",
+ "chalk": "^4.1.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=14"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-convert": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=8"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/chalk?sponsor=1"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "color-name": "~1.1.4"
+ },
+ "engines": {
+ "node": ">=7.0.0"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom/node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom/node_modules/has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@testing-library/dom/node_modules/supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "has-flag": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "14.3.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz",
+ "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5",
+ "@testing-library/dom": "^9.0.0",
+ "@types/react-dom": "^18.0.0"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0",
+ "react-dom": "^18.0.0"
+ }
+ },
"node_modules/@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -4091,6 +4249,13 @@
"node": ">=10.13.0"
}
},
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/babel__core": {
"version": "7.20.0",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz",
@@ -4354,6 +4519,16 @@
"csstype": "^3.0.2"
}
},
+ "node_modules/@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^18.0.0"
+ }
+ },
"node_modules/@types/resolve": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz",
@@ -7528,6 +7703,13 @@
"url": "https://github.com/sponsors/fb55"
}
},
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/cssdb": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.4.1.tgz",
@@ -8080,6 +8262,13 @@
"node": ">=6.0.0"
}
},
+ "node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/dom-converter": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@@ -10953,6 +11142,16 @@
"node": ">=0.8.19"
}
},
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/indexes-of": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz",
@@ -14119,6 +14318,16 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
"node_modules/magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@@ -14457,6 +14666,16 @@
"node": ">=6"
}
},
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
"node_modules/mini-css-extract-plugin": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz",
@@ -18634,6 +18853,20 @@
"node": ">=6.0.0"
}
},
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -20247,6 +20480,19 @@
"node": ">=6"
}
},
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -22632,6 +22878,12 @@
}
},
"dependencies": {
+ "@adobe/css-tools": {
+ "version": "4.4.4",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
+ "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
+ "dev": true
+ },
"@ampproject/remapping": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
@@ -25311,6 +25563,112 @@
}
}
},
+ "@testing-library/dom": {
+ "version": "9.3.4",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.4.tgz",
+ "integrity": "sha512-FlS4ZWlp97iiNWig0Muq8p+3rVDjRiYE+YKGbAqXOu9nwJFFOdL00kFpz42M+4huzYi86vAK1sOOfyOG45muIQ==",
+ "dev": true,
+ "requires": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.1.3",
+ "chalk": "^4.1.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "pretty-format": "^27.0.2"
+ },
+ "dependencies": {
+ "ansi-styles": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
+ "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
+ "dev": true,
+ "requires": {
+ "color-convert": "^2.0.1"
+ }
+ },
+ "chalk": {
+ "version": "4.1.2",
+ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
+ "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
+ "dev": true,
+ "requires": {
+ "ansi-styles": "^4.1.0",
+ "supports-color": "^7.1.0"
+ }
+ },
+ "color-convert": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
+ "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
+ "dev": true,
+ "requires": {
+ "color-name": "~1.1.4"
+ }
+ },
+ "color-name": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
+ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
+ "dev": true
+ },
+ "dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true
+ },
+ "has-flag": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
+ "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==",
+ "dev": true
+ },
+ "supports-color": {
+ "version": "7.2.0",
+ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
+ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
+ "dev": true,
+ "requires": {
+ "has-flag": "^4.0.0"
+ }
+ }
+ }
+ },
+ "@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "requires": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "dependencies": {
+ "picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true
+ }
+ }
+ },
+ "@testing-library/react": {
+ "version": "14.3.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-14.3.1.tgz",
+ "integrity": "sha512-H99XjUhWQw0lTgyMN05W3xQG1Nh4lq574D8keFf1dDoNTJgp66VbJozRaczoF+wsiaPJNt/TcnfpLGufGxSrZQ==",
+ "dev": true,
+ "requires": {
+ "@babel/runtime": "^7.12.5",
+ "@testing-library/dom": "^9.0.0",
+ "@types/react-dom": "^18.0.0"
+ }
+ },
"@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -25323,6 +25681,12 @@
"integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==",
"dev": true
},
+ "@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true
+ },
"@types/babel__core": {
"version": "7.20.0",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.0.tgz",
@@ -25586,6 +25950,13 @@
"csstype": "^3.0.2"
}
},
+ "@types/react-dom": {
+ "version": "18.3.7",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
+ "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
+ "dev": true,
+ "requires": {}
+ },
"@types/resolve": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-0.0.8.tgz",
@@ -27931,6 +28302,12 @@
"integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==",
"dev": true
},
+ "css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true
+ },
"cssdb": {
"version": "7.4.1",
"resolved": "https://registry.npmjs.org/cssdb/-/cssdb-7.4.1.tgz",
@@ -28365,6 +28742,12 @@
"esutils": "^2.0.2"
}
},
+ "dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true
+ },
"dom-converter": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz",
@@ -30537,6 +30920,12 @@
"integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
"dev": true
},
+ "indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true
+ },
"indexes-of": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz",
@@ -32914,6 +33303,12 @@
"yallist": "^3.0.2"
}
},
+ "lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true
+ },
"magic-string": {
"version": "0.25.9",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.25.9.tgz",
@@ -33176,6 +33571,12 @@
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==",
"dev": true
},
+ "min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true
+ },
"mini-css-extract-plugin": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.7.2.tgz",
@@ -36124,6 +36525,16 @@
"minimatch": "^3.0.5"
}
},
+ "redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "requires": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ }
+ },
"regenerate": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz",
@@ -37395,6 +37806,15 @@
"integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==",
"dev": true
},
+ "strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "requires": {
+ "min-indent": "^1.0.0"
+ }
+ },
"strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
diff --git a/package.json b/package.json
index 46e21b8..dd89d8e 100644
--- a/package.json
+++ b/package.json
@@ -8,12 +8,31 @@
"main": "dist/index.js",
"module": "dist/index.modern.js",
"source": "src/index.tsx",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.modern.js",
+ "require": "./dist/index.js"
+ },
+ "./app": {
+ "types": "./dist/app.d.ts",
+ "import": "./dist/app.modern.js",
+ "require": "./dist/app.js"
+ },
+ "./pages": {
+ "types": "./dist/pages.d.ts",
+ "import": "./dist/pages.modern.js",
+ "require": "./dist/pages.js"
+ },
+ "./dist/index.css": "./dist/index.css",
+ "./package.json": "./package.json"
+ },
"engines": {
"node": "^20 || ^22 || ^24"
},
"scripts": {
- "build": "microbundle-crl --no-compress --format modern,cjs && node scripts/add-use-client.js",
- "start": "microbundle-crl watch --no-compress --format modern,cjs",
+ "build": "rimraf dist && microbundle-crl --no-compress --format modern,cjs -i src/index.tsx -i src/app.tsx -i src/pages.tsx && node scripts/postbuild.js",
+ "start": "microbundle-crl watch --no-compress --format modern,cjs -i src/index.tsx -i src/app.tsx -i src/pages.tsx",
"prepare": "run-s build",
"test": "run-s test:types test:unit",
"test:unit": "cross-env CI=1 react-scripts test --env=jsdom",
@@ -34,6 +53,8 @@
},
"devDependencies": {
"@playwright/test": "1.59.1",
+ "@testing-library/jest-dom": "^6.9.1",
+ "@testing-library/react": "^14.3.1",
"@types/jest": "^27.5.0",
"@types/lodash": "^4.14.182",
"@types/node": "^17.0.31",
diff --git a/scripts/add-use-client.js b/scripts/add-use-client.js
deleted file mode 100644
index aa4df50..0000000
--- a/scripts/add-use-client.js
+++ /dev/null
@@ -1,27 +0,0 @@
-const fs = require('fs')
-const path = require('path')
-
-const DIRECTIVE = "'use client';\n"
-const ESM_MARKER = "Object.defineProperty(exports, '__esModule', { value: true });\n"
-
-function patch(file, { addDirective, addEsModule }) {
- if (!fs.existsSync(file)) return
- const contents = fs.readFileSync(file, 'utf8')
- const hasDirective =
- contents.startsWith("'use client'") || contents.startsWith('"use client"')
- const hasEsModule = contents.includes('__esModule')
- let prefix = ''
- if (addDirective && !hasDirective) prefix += DIRECTIVE
- if (addEsModule && !hasEsModule) prefix += ESM_MARKER
- if (!prefix) return
- fs.writeFileSync(file, prefix + contents)
-}
-
-const root = path.join(__dirname, '..')
-// CJS bundle needs both: 'use client' for Next.js to treat the module as a
-// client component (microbundle strips the source directive), and the
-// __esModule marker so consumers' default-import interop resolves
-// `exports.default` instead of the whole exports object.
-patch(path.join(root, 'dist/index.js'), { addDirective: true, addEsModule: true })
-// ESM bundle only needs the directive; named/default exports work natively.
-patch(path.join(root, 'dist/index.modern.js'), { addDirective: true, addEsModule: false })
diff --git a/scripts/postbuild.js b/scripts/postbuild.js
new file mode 100644
index 0000000..214fc72
--- /dev/null
+++ b/scripts/postbuild.js
@@ -0,0 +1,125 @@
+const fs = require('fs')
+const path = require('path')
+
+const dist = path.join(__dirname, '..', 'dist')
+
+const renameMap = [
+ ['index.tsx.js', 'index.js'],
+ ['index.tsx.js.map', 'index.js.map'],
+ ['index.tsx.modern.js', 'index.modern.js'],
+ ['index.tsx.modern.js.map', 'index.modern.js.map'],
+ ['index.tsx.css', 'index.css'],
+ ['app.tsx.js', 'app.js'],
+ ['app.tsx.js.map', 'app.js.map'],
+ ['app.tsx.modern.js', 'app.modern.js'],
+ ['app.tsx.modern.js.map', 'app.modern.js.map'],
+ ['pages.tsx.js', 'pages.js'],
+ ['pages.tsx.js.map', 'pages.js.map'],
+ ['pages.tsx.modern.js', 'pages.modern.js'],
+ ['pages.tsx.modern.js.map', 'pages.modern.js.map']
+]
+
+function renameBundles() {
+ for (const [from, to] of renameMap) {
+ const src = path.join(dist, from)
+ if (!fs.existsSync(src)) continue
+ fs.renameSync(src, path.join(dist, to))
+ }
+ for (const file of fs.readdirSync(dist)) {
+ if (!file.endsWith('.map')) continue
+ const p = path.join(dist, file)
+ const json = JSON.parse(fs.readFileSync(p, 'utf8'))
+ if (typeof json.file === 'string') {
+ json.file = json.file.replace(/\.tsx\./g, '.')
+ fs.writeFileSync(p, JSON.stringify(json))
+ }
+ }
+}
+
+function copyDeclarations() {
+ const srcDir = path.join(dist, 'src')
+ if (!fs.existsSync(srcDir)) return
+
+ const walk = (dir, base) => {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const from = path.join(dir, entry.name)
+ const rel = path.join(base, entry.name)
+ const to = path.join(dist, rel)
+ if (entry.isDirectory()) {
+ fs.mkdirSync(to, { recursive: true })
+ walk(from, rel)
+ } else if (entry.name.endsWith('.d.ts')) {
+ fs.copyFileSync(from, to)
+ }
+ }
+ }
+ walk(srcDir, '')
+}
+
+function cleanStale() {
+ const staleEntries = ['src', 'tests', 'playwright.config.d.ts']
+ for (const name of staleEntries) {
+ const p = path.join(dist, name)
+ if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true })
+ }
+ const walk = (dir) => {
+ if (!fs.existsSync(dir)) return
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ const p = path.join(dir, entry.name)
+ if (entry.isDirectory()) {
+ walk(p)
+ } else if (entry.name.endsWith('.test.d.ts')) {
+ fs.rmSync(p)
+ }
+ }
+ }
+ walk(dist)
+}
+
+const DIRECTIVE = "'use client';\n"
+const ESM_MARKER = "Object.defineProperty(exports, '__esModule', { value: true });\n"
+
+function patch(file, { addDirective, addEsModule }) {
+ if (!fs.existsSync(file)) {
+ throw new Error(
+ `postbuild: expected build output at ${file}. ` +
+ 'Did the microbundle build run? Did its output filename change?'
+ )
+ }
+ const contents = fs.readFileSync(file, 'utf8')
+ const hasDirective =
+ contents.startsWith("'use client'") || contents.startsWith('"use client"')
+ const hasEsModule = contents.includes('__esModule')
+ let prefix = ''
+ if (addDirective && !hasDirective) prefix += DIRECTIVE
+ if (addEsModule && !hasEsModule) prefix += ESM_MARKER
+ if (!prefix) {
+ console.log(`postbuild: ${path.relative(process.cwd(), file)} already patched`)
+ return
+ }
+ fs.writeFileSync(file, prefix + contents)
+ console.log(`postbuild: patched ${path.relative(process.cwd(), file)}`)
+}
+
+renameBundles()
+copyDeclarations()
+cleanStale()
+
+// Root bundle re-exports both adapters. Keep `'use client'` so consumers using
+// `import { AppPagination } from '@etchteam/next-pagination'` (the back-compat
+// path) get a valid client-component boundary. Pages-only consumers should
+// import from `@etchteam/next-pagination/pages` to opt out of the directive.
+// CJS bundles need the `__esModule` marker so default-import interop resolves
+// `exports.default` instead of the whole exports object.
+patch(path.join(dist, 'index.js'), { addDirective: true, addEsModule: true })
+patch(path.join(dist, 'index.modern.js'), { addDirective: true, addEsModule: false })
+
+// App-router-only entry: same as root but no cross-router footprint.
+patch(path.join(dist, 'app.js'), { addDirective: true, addEsModule: true })
+patch(path.join(dist, 'app.modern.js'), { addDirective: true, addEsModule: false })
+
+// Pages-router-only entry: no `'use client'` (pages router has no server/client
+// component split). CJS still needs the `__esModule` marker for default-import
+// interop.
+patch(path.join(dist, 'pages.js'), { addDirective: false, addEsModule: true })
+patch(path.join(dist, 'pages.modern.js'), { addDirective: false, addEsModule: false })
diff --git a/src/adapters/app.test.tsx b/src/adapters/app.test.tsx
new file mode 100644
index 0000000..cab1056
--- /dev/null
+++ b/src/adapters/app.test.tsx
@@ -0,0 +1,98 @@
+import React from 'react'
+import { render, screen, fireEvent, act } from '@testing-library/react'
+import '@testing-library/jest-dom'
+
+const mockPush = jest.fn()
+let mockSearchParams: URLSearchParams | null
+let mockPathname: string | null
+
+jest.mock('next/navigation', () => ({
+ useRouter: () => ({ push: mockPush }),
+ usePathname: () => mockPathname,
+ useSearchParams: () => mockSearchParams
+}))
+
+import AppPagination from './app'
+
+beforeEach(() => {
+ mockPush.mockReset()
+ mockSearchParams = new URLSearchParams()
+ mockPathname = '/list'
+})
+
+describe('AppPagination', () => {
+ it('renders page 1 with default search params', async () => {
+ await act(async () => {
+ render()
+ })
+ expect(screen.getByLabelText('Page 1, current page')).toBeInTheDocument()
+ })
+
+ it('reads a single-valued page param', async () => {
+ mockSearchParams = new URLSearchParams('page=2')
+ await act(async () => {
+ render()
+ })
+ expect(screen.getByLabelText('Page 2, current page')).toBeInTheDocument()
+ })
+
+ it('preserves multi-valued query params (does not collapse to last value)', async () => {
+ mockSearchParams = new URLSearchParams('tag=a&tag=b&page=2')
+ await act(async () => {
+ render()
+ })
+ expect(screen.getByLabelText('Page 2, current page')).toBeInTheDocument()
+ })
+
+ it('clamps invalid page values silently', async () => {
+ mockSearchParams = new URLSearchParams('page=abc')
+ await act(async () => {
+ render()
+ })
+ expect(screen.getByLabelText('Page 1, current page')).toBeInTheDocument()
+ })
+
+ it('calls router.push with the expected URL when changing page size', async () => {
+ mockSearchParams = new URLSearchParams('page=3')
+ await act(async () => {
+ render()
+ })
+ const select = screen.getByLabelText('per page') as HTMLSelectElement
+ fireEvent.change(select, { target: { value: '40' } })
+ expect(mockPush).toHaveBeenCalledTimes(1)
+ const url = mockPush.mock.calls[0][0] as string
+ expect(url).toContain('size=40')
+ expect(url).toContain('page=1')
+ expect(url.startsWith('/list?')).toBe(true)
+ })
+
+ describe('null context', () => {
+ let warnSpy: jest.SpyInstance
+
+ beforeEach(() => {
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
+ })
+
+ afterEach(() => {
+ warnSpy.mockRestore()
+ })
+
+ it('renders nothing and warns when useSearchParams returns null', async () => {
+ mockSearchParams = null
+ const { container } = await act(async () =>
+ render()
+ )
+ expect(container.firstChild).toBeNull()
+ expect(warnSpy).toHaveBeenCalled()
+ })
+
+ it('renders nothing and warns when usePathname returns null', async () => {
+ mockPathname = null
+ const { container } = await act(async () =>
+ render()
+ )
+ expect(container.firstChild).toBeNull()
+ expect(warnSpy).toHaveBeenCalled()
+ })
+ })
+})
diff --git a/src/adapters/app.tsx b/src/adapters/app.tsx
index ca9a401..e249958 100644
--- a/src/adapters/app.tsx
+++ b/src/adapters/app.tsx
@@ -10,21 +10,31 @@ const AppPagination = (props: PaginationProps) => {
const pathname = usePathname()
const searchParams = useSearchParams()
- const query: Record = {}
- if (searchParams) {
- const keys = new Set()
- searchParams.forEach((_value, key) => keys.add(key))
- keys.forEach((key) => {
- const values = searchParams.getAll(key)
- query[key] = values.length > 1 ? values : values[0]
- })
+ if (searchParams === null || pathname === null) {
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn(
+ '[next-pagination] AppPagination rendered without an app-router ' +
+ 'search-params/pathname context. Wrap it in . ' +
+ 'See https://nextjs.org/docs/app/api-reference/functions/use-search-params'
+ )
+ }
+ return null
}
+ const query: Record = {}
+ const seen = new Set()
+ searchParams.forEach((_value, key) => {
+ if (seen.has(key)) return
+ seen.add(key)
+ const values = searchParams.getAll(key)
+ query[key] = values.length > 1 ? values : values[0]
+ })
+
return (
{
router.push(url)
diff --git a/src/app.tsx b/src/app.tsx
new file mode 100644
index 0000000..0cb612b
--- /dev/null
+++ b/src/app.tsx
@@ -0,0 +1,6 @@
+import AppPagination from './adapters/app'
+
+export { default as AppPagination } from './adapters/app'
+export type { PaginationProps } from './pagination'
+
+export default AppPagination
diff --git a/src/index.test.ts b/src/index.test.ts
index e622bfa..ac5526b 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -3,14 +3,11 @@ import { getSizes } from './lib/sizes'
import assert from 'assert'
describe('Pagination', () => {
- it('is truthy', () => {
- expect(Pagination).toBeTruthy()
- })
it('default export is PagesPagination', () => {
expect(Pagination).toBe(PagesPagination)
})
- it('exports AppPagination', () => {
- expect(AppPagination).toBeTruthy()
+ it('exposes AppPagination as a distinct named export', () => {
+ expect(AppPagination).not.toBe(PagesPagination)
})
})
diff --git a/src/pages.tsx b/src/pages.tsx
new file mode 100644
index 0000000..23d202e
--- /dev/null
+++ b/src/pages.tsx
@@ -0,0 +1,6 @@
+import PagesPagination from './adapters/pages'
+
+export { default as PagesPagination } from './adapters/pages'
+export type { PaginationProps } from './pagination'
+
+export default PagesPagination
diff --git a/src/pagination.test.tsx b/src/pagination.test.tsx
new file mode 100644
index 0000000..51b6f8b
--- /dev/null
+++ b/src/pagination.test.tsx
@@ -0,0 +1,274 @@
+import React from 'react'
+import { render, screen, fireEvent, act } from '@testing-library/react'
+import '@testing-library/jest-dom'
+
+import Pagination, { RoutingAdapter } from './pagination'
+
+const stubAdapter = (overrides: Partial = {}): RoutingAdapter => ({
+ pathname: '/list',
+ query: {},
+ push: jest.fn(),
+ ...overrides
+})
+
+describe('Pagination core', () => {
+ it('renders the navigation landmark with both adapters absent before mount, then visible', async () => {
+ await act(async () => {
+ render()
+ })
+ expect(screen.getByRole('navigation', { name: 'pagination' })).toBeInTheDocument()
+ })
+
+ describe('page-1 boundary', () => {
+ it('disables the previous-page link', async () => {
+ await act(async () => {
+ render()
+ })
+ expect(screen.getByLabelText('No previous page available')).toBeInTheDocument()
+ expect(screen.queryByLabelText('Previous page')).not.toBeInTheDocument()
+ })
+
+ it('marks page 1 as the current page', async () => {
+ await act(async () => {
+ render()
+ })
+ expect(screen.getByLabelText('Page 1, current page')).toBeInTheDocument()
+ })
+ })
+
+ describe('last-page boundary', () => {
+ it('disables the next-page link when on the last page', async () => {
+ await act(async () => {
+ render(
+
+ )
+ })
+ expect(screen.getByLabelText('No next page available')).toBeInTheDocument()
+ expect(screen.queryByLabelText('Next page')).not.toBeInTheDocument()
+ })
+ })
+
+ describe('invalid ?page= values are clamped silently', () => {
+ it('treats non-numeric page as page 1', async () => {
+ await act(async () => {
+ render(
+
+ )
+ })
+ expect(screen.getByLabelText('Page 1, current page')).toBeInTheDocument()
+ expect(screen.getByLabelText('No previous page available')).toBeInTheDocument()
+ })
+
+ it('clamps page beyond range to the last page', async () => {
+ await act(async () => {
+ render(
+
+ )
+ })
+ expect(screen.getByLabelText('Page 5, current page')).toBeInTheDocument()
+ expect(screen.getByLabelText('No next page available')).toBeInTheDocument()
+ })
+
+ it('treats negative page as page 1', async () => {
+ await act(async () => {
+ render(
+
+ )
+ })
+ expect(screen.getByLabelText('Page 1, current page')).toBeInTheDocument()
+ })
+
+ it('treats page=0 as page 1', async () => {
+ await act(async () => {
+ render(
+
+ )
+ })
+ expect(screen.getByLabelText('Page 1, current page')).toBeInTheDocument()
+ })
+
+ it('uses the first value of a multi-valued page param', async () => {
+ await act(async () => {
+ render(
+
+ )
+ })
+ expect(screen.getByLabelText('Page 2, current page')).toBeInTheDocument()
+ })
+ })
+
+ describe('invalid ?size= values fall back to default', () => {
+ it('non-numeric size falls back to first valid size', async () => {
+ await act(async () => {
+ render(
+
+ )
+ })
+ const select = screen.getByLabelText('per page') as HTMLSelectElement
+ expect(select.value).toBe('20')
+ })
+
+ it('size not in sizes list falls back to first valid size', async () => {
+ await act(async () => {
+ render(
+
+ )
+ })
+ const select = screen.getByLabelText('per page') as HTMLSelectElement
+ expect(select.value).toBe('20')
+ })
+ })
+
+ describe('invalid total renders an empty pagination strip', () => {
+ it('treats negative total as zero', async () => {
+ await act(async () => {
+ render()
+ })
+ expect(screen.getByLabelText('No previous page available')).toBeInTheDocument()
+ expect(screen.getByLabelText('No next page available')).toBeInTheDocument()
+ })
+
+ it('treats undefined total as zero', async () => {
+ await act(async () => {
+ render(
+
+ )
+ })
+ expect(screen.getByLabelText('No previous page available')).toBeInTheDocument()
+ expect(screen.getByLabelText('No next page available')).toBeInTheDocument()
+ })
+ })
+
+ describe('label overrides', () => {
+ it('renders perPageText and setPageSizeText overrides', async () => {
+ await act(async () => {
+ render(
+
+ )
+ })
+ expect(screen.getByText('por página')).toBeInTheDocument()
+ expect(screen.getByText('Establecer')).toBeInTheDocument()
+ })
+ })
+
+ describe('custom linkComponent', () => {
+ it('receives href, prefetch, passHref, legacyBehavior, plus linkProps spread', async () => {
+ const seen: Record[] = []
+ const Custom = (props: Record) => {
+ seen.push(props)
+ return <>{props.children as React.ReactNode}>
+ }
+ await act(async () => {
+ render(
+
+ )
+ })
+ const sample = seen.find((p) => p.href === '?page=1')
+ expect(sample).toBeDefined()
+ expect(sample).toMatchObject({
+ prefetch: false,
+ passHref: true,
+ legacyBehavior: true,
+ 'data-extra': 'yes'
+ })
+ })
+ })
+
+ describe('page-size onChange', () => {
+ it('calls routing.push with page=1 and the new size', async () => {
+ const push = jest.fn()
+ await act(async () => {
+ render(
+
+ )
+ })
+ const select = screen.getByLabelText('per page') as HTMLSelectElement
+ fireEvent.change(select, { target: { value: '40' } })
+ expect(push).toHaveBeenCalledTimes(1)
+ const url = push.mock.calls[0][0] as string
+ expect(url).toContain('size=40')
+ expect(url).toContain('page=1')
+ expect(url.startsWith('/list?')).toBe(true)
+ })
+ })
+
+ describe('SEO Head injection (pages-router-shaped adapter)', () => {
+ it('renders on page 2 via adapter Head', async () => {
+ const captured: React.ReactNode[] = []
+ const Head: RoutingAdapter['Head'] = ({ children }) => {
+ captured.push(children)
+ return null
+ }
+ await act(async () => {
+ render(
+
+ )
+ })
+ const flat = captured.flat() as React.ReactElement[]
+ const prev = flat.find(
+ (el) => React.isValidElement(el) && (el.props as { rel?: string }).rel === 'prev'
+ )
+ expect(prev).toBeDefined()
+ })
+
+ it('does not render prev link on page 1', async () => {
+ const captured: React.ReactNode[] = []
+ const Head: RoutingAdapter['Head'] = ({ children }) => {
+ captured.push(children)
+ return null
+ }
+ await act(async () => {
+ render()
+ })
+ const flat = captured.flat() as React.ReactElement[]
+ const prev = flat.find(
+ (el) => React.isValidElement(el) && (el.props as { rel?: string }).rel === 'prev'
+ )
+ expect(prev).toBeUndefined()
+ })
+ })
+})
diff --git a/src/pagination.tsx b/src/pagination.tsx
index 70592a8..36aa294 100644
--- a/src/pagination.tsx
+++ b/src/pagination.tsx
@@ -81,11 +81,28 @@ const Pagination = ({
if (!mounted) return null
const query = pickBy({ ...routing.query }, (q) => !isEmpty(q))
- const currentPage = Number(query.page || 1)
const cSizes = getSizes(sizes)
- const pageSize = Number(query.size) || cSizes[0]
- const isLastPage = currentPage * pageSize >= total
- const pageNumbers = getPageNumbers({ currentPage, pageSize, total })
+ const safeTotal =
+ Number.isFinite(total) && total >= 0 ? Math.floor(total) : 0
+
+ const rawSize = Array.isArray(query.size) ? query.size[0] : query.size
+ const parsedSize = Number.parseInt(rawSize ?? '', 10)
+ const pageSize = cSizes.includes(parsedSize) ? parsedSize : cSizes[0]
+
+ const lastPage = Math.max(1, Math.ceil(safeTotal / pageSize))
+ const rawPage = Array.isArray(query.page) ? query.page[0] : query.page
+ const parsedPage = Number.parseInt(rawPage ?? '', 10)
+ const currentPage =
+ Number.isFinite(parsedPage) && parsedPage >= 1
+ ? Math.min(parsedPage, lastPage)
+ : 1
+
+ const isLastPage = currentPage * pageSize >= safeTotal
+ const pageNumbers = getPageNumbers({
+ currentPage,
+ pageSize,
+ total: safeTotal
+ })
const path = routing.pathname
From d23af9aec0f4460666b10a623479f1fca52c0b64 Mon Sep 17 00:00:00 2001
From: Josh Rose <1677846+JoshTheWanderer@users.noreply.github.com>
Date: Mon, 27 Apr 2026 12:32:55 +0200
Subject: [PATCH 7/7] ci: also trigger on PRs targeting modernize
The long-running modernize branch is the base for #140 and future
modernization PRs, so CI needs to fire on PRs into it as well as main.
Co-Authored-By: Claude Opus 4.7 (1M context)
---
.github/workflows/ci.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 829a199..7fb201b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -2,7 +2,7 @@
name: 👮 CI
on:
pull_request:
- branches: [main]
+ branches: [main, modernize]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true