├── .editorconfig ├── .eslintignore ├── .eslintrc ├── .github ├── mergify.yml └── workflows │ └── ci.yml ├── .gitignore ├── .megalinter.yml ├── .npmrc ├── .nvmrc ├── .prettierrc ├── .trufflehog-ignore ├── LICENSE ├── README.md ├── SECURITY.md ├── example ├── README.md ├── next.config.js ├── package-lock.json ├── package.json ├── pages │ ├── [dynamic].js │ ├── _app.js │ └── index.js ├── public │ ├── example.png │ ├── favicon.ico │ ├── index.html │ └── manifest.json └── styles │ ├── main.css │ └── theme.module.scss ├── package-lock.json ├── package.json ├── src ├── .eslintrc ├── components │ ├── Icon.tsx │ ├── Item.tsx │ ├── Link.tsx │ ├── List.tsx │ └── Select.tsx ├── declarations.d.ts ├── index.module.scss ├── index.test.ts ├── index.tsx ├── lib │ ├── get-page-numbers.ts │ └── sizes.ts └── react-app-env.d.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | build/ 2 | dist/ 3 | node_modules/ 4 | .snapshots/ 5 | *.min.js -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "plugins": [ 4 | "@typescript-eslint" 5 | ], 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "prettier/react", 11 | "plugin:prettier/recommended" 12 | ], 13 | "env": { 14 | "node": true 15 | }, 16 | "parserOptions": { 17 | "ecmaVersion": 2020, 18 | "ecmaFeatures": { 19 | "legacyDecorators": true, 20 | "jsx": true 21 | } 22 | }, 23 | "settings": { 24 | "react": { 25 | "version": "16" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.github/mergify.yml: -------------------------------------------------------------------------------- 1 | --- 2 | pull_request_rules: 3 | - name: Auto-merge dependabot updates when checks pass 4 | conditions: 5 | - author=dependabot[bot] 6 | - label!=wontfix 7 | actions: 8 | review: 9 | type: APPROVE 10 | message: Automatically approving dependabot 11 | merge: 12 | method: merge 13 | - name: Auto-merge when all checks pass and the PR has been approved 14 | conditions: 15 | - "#review-requested=0" 16 | - "#approved-reviews-by>=1" 17 | actions: 18 | merge: 19 | method: merge 20 | - name: Ask for reviews 21 | conditions: 22 | - -closed 23 | - -draft 24 | - -author=dependabot[bot] 25 | actions: 26 | request_reviews: 27 | teams: 28 | - devs 29 | - name: Assign PR to its author 30 | conditions: 31 | - "#files=1" 32 | actions: 33 | assign: 34 | add_users: 35 | - "{{author}}" 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: 👮 CI 3 | on: 4 | pull_request: 5 | branches: [main] 6 | concurrency: 7 | group: ${{ github.workflow }}-${{ github.ref }} 8 | cancel-in-progress: true 9 | permissions: read-all 10 | jobs: 11 | build: 12 | name: 🔨 Build 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-node@v4 17 | with: 18 | node-version-file: .nvmrc 19 | cache: npm 20 | - run: npm ci 21 | - run: npm run build 22 | test: 23 | name: 🧪 Test 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v4 27 | - uses: actions/setup-node@v4 28 | with: 29 | node-version-file: .nvmrc 30 | cache: npm 31 | - run: npm ci 32 | - run: npm test 33 | lint: 34 | name: 🧹 Lint 35 | runs-on: ubuntu-latest 36 | permissions: 37 | contents: read 38 | pull-requests: write 39 | steps: 40 | - uses: actions/checkout@v4 41 | with: 42 | fetch-depth: 0 43 | - uses: actions/setup-node@v4 44 | with: 45 | node-version-file: .nvmrc 46 | cache: npm 47 | - run: npm ci 48 | - uses: oxsecurity/megalinter/flavors/javascript@v7 49 | env: 50 | VALIDATE_ALL_CODEBASE: false 51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # See https://help.github.com/ignore-files/ for more about ignoring files. 3 | 4 | # dependencies 5 | node_modules 6 | 7 | # builds 8 | build 9 | dist 10 | .rpt2_cache 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | 20 | npm-debug.log* 21 | yarn-debug.log* 22 | yarn-error.log* 23 | 24 | .next 25 | -------------------------------------------------------------------------------- /.megalinter.yml: -------------------------------------------------------------------------------- 1 | # Configuration file for MegaLinter 2 | # 3 | # See all available variables at https://megalinter.io/latest/config-file/ and in 4 | # linters documentation 5 | --- 6 | APPLY_FIXES: none 7 | 8 | DISABLE: 9 | - COPYPASTE 10 | - SPELL 11 | 12 | DISABLE_LINTERS: 13 | # We use stylelint for CSS and SCSS linting 14 | - CSS_SCSS_LINT 15 | # Disable in favour of eslint 16 | - JAVASCRIPT_STANDARD 17 | # We use dependabot for vulnerability monitoring and patching 18 | - REPOSITORY_GRYPE 19 | # Not needed in this repository 20 | - REPOSITORY_TRIVY 21 | # Disable in favour of eslint 22 | - TYPESCRIPT_STANDARD 23 | # Link check flags localhost links in the contributing docs 24 | - MARKDOWN_MARKDOWN_LINK_CHECK 25 | # V8R keeps giving false positives 26 | - YAML_V8R 27 | 28 | SHOW_ELAPSED_TIME: true 29 | 30 | FILEIO_REPORTER: false 31 | 32 | # Config paths 33 | CSS_STYLELINT_CONFIG_FILE: stylelint.config.js 34 | JAVASCRIPT_ES_CONFIG_FILE: .eslintrc 35 | TYPESCRIPT_ES_CONFIG_FILE: .eslintrc 36 | 37 | # Executable overrides 38 | CSS_STYLELINT_CLI_EXECUTABLE: ['./node_modules/.bin/stylelint'] 39 | 40 | # Linters configuration 41 | REPOSITORY_GITLEAKS_DISABLE_ERRORS: true 42 | REPOSITORY_TRUFFLEHOG_ARGUMENTS: --exclude_paths .trufflehog-ignore 43 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | audit=false 2 | noFund=true 3 | preferOffline=true 4 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.18.1 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "jsxSingleQuote": true, 4 | "semi": false, 5 | "tabWidth": 2, 6 | "bracketSpacing": true, 7 | "jsxBracketSameLine": false, 8 | "arrowParens": "always", 9 | "trailingComma": "none" 10 | } 11 | -------------------------------------------------------------------------------- /.trufflehog-ignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | dist 4 | example/.next 5 | example/node_modules 6 | example/next-env.d.ts 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2024 Etch Software Limited 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # next-pagination 2 | 3 | > The best damn pagination component. For Next.js 4 | 5 | [![NPM](https://img.shields.io/npm/v/@etchteam/next-pagination.svg)](https://www.npmjs.com/package/@etchteam/next-pagination) [![JavaScript Style Guide](https://img.shields.io/badge/code_style-standard-brightgreen.svg)](https://standardjs.com) 6 | 7 | TL;DR Just show me the [DEMO](https://etchteam.github.io/next-pagination) 8 | 9 | An example of the pagination UI in use 10 | 11 | ## Why use this pagination module? 12 | 13 | - **Accessible.** Semantic HTML and fully marked up with appropriate aria roles for assisted browsing. 14 | - **Usable.** The base CSS styles account for keyboard focus states and fat finger touch targets. 15 | - **Responsive.** Works on all devices. 16 | - **Themeable.** Make it look however you want. 17 | - **Self contained.** There's only one required prop to get going. The rest of the logic is handled for you. 18 | - **Works with Next.** Integrated with the Next.js router. 19 | 20 | ## Install 21 | 22 | ```bash 23 | npm install --save @etchteam/next-pagination 24 | ``` 25 | 26 | ## Usage 27 | 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. 28 | 29 | ```jsx 30 | import React, { Component } from 'react' 31 | 32 | import Pagination from '@etchteam/next-pagination' 33 | 34 | class Example extends Component { 35 | render() { 36 | return 37 | } 38 | } 39 | ``` 40 | 41 | You will need to import the CSS, either in your `_app.js`, or in your Sass build. 42 | 43 | ```jsx 44 | import '@etchteam/next-pagination/dist/index.css' 45 | ``` 46 | 47 | When used, the pagination component will reload the same route with added pagination query params. 48 | 49 | - `page` for the page number the user is on. 50 | - `size` for the number of results per page. 51 | 52 | e.g. ?page=4&size=20 53 | 54 | The **default page** is 1. The **default size** is 20. 55 | 56 | You'll need to load the actual data from your API yourself. We're only here for the front-end! 57 | 58 | ## Props 59 | 60 | | Name | Type | Description | 61 | | ------------------------ | ---------- | ----------------------------------------- | 62 | | `total` | `Number` | **Required.** The total number of pages. | 63 | | `theme` | `Object` | A CSS modules style object. | 64 | | `sizes` | `Array` | An array of page size numbers | 65 | | `perPageText` | `String` | Label for the page size dropdown | 66 | | `setPageSizeText` | `String` | Label for the invisible page size button | 67 | | `linkProps` | `Object` | Extra props to pass to the next.js links | 68 | 69 | ## Theming 70 | Next.js natively supports **CSS modules**, so this component supports injecting CSS module styles. 71 | 72 | Import the styles as you would for a normal component, but pass them as props. 73 | 74 | ```jsx 75 | [...] 76 | import styles from '/my/path/to/styles.module.css' 77 | 78 | class Example extends Component { 79 | render() { 80 | return 81 | } 82 | } 83 | ``` 84 | 85 | The theme uses BEM class naming with the base class `next-pagination`. The file `/src/index.module.scss` should give you a solid idea of what's needed. 86 | 87 | ## Contribute 88 | 89 | This package was created with [create-react-library](https://github.com/transitive-bullshit/create-react-library#readme). 90 | 91 | ### Setup 92 | 93 | To get set up you'll need to run `npm install && cd example && npm install` 94 | 95 | ## Development 96 | 97 | In the root folder, run `npm run start` 98 | **At the same time**, in the example folder, run `npm run dev` 99 | Then head over to `localhost:3000` to see the example running. 100 | 101 | ## Deploy the example 102 | 103 | In the root folder run `npm run deploy` to deploy the example to github pages on the `gh-pages` branch of your repo. 104 | 105 | ## Publish to npm 106 | 107 | Feeling confident? Run `npm publish` to send the latest version to npm. 108 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We only support the [latest version](https://www.npmjs.com/package/@etchteam/next-pagination/v/latest) of this package. 6 | 7 | ## Reporting a Code Vulnerability 8 | 9 | Please report suspected security vulnerabilities to security@etch.co, with a working proof of concept. 10 | 11 | We’ll try to respond within two working days (we work Monday to Friday, so if you report something on Friday, you probably won’t hear back until Monday or Tuesday). 12 | 13 | Once we’ve confirmed the vulnerability, we’ll get a fix out as soon as possible, and keep you updated throughout. 14 | 15 | ## Reporting a Vulnerable Package 16 | 17 | If you have a concern about a package that we’re using, and there isn’t an open pull request or issue, then please open one! 18 | 19 | We try to stay on top of package updates, but we always welcome extra help. 20 | -------------------------------------------------------------------------------- /example/README.md: -------------------------------------------------------------------------------- 1 | This example was bootstrapped with [Next.js](https://nextjs.org/). 2 | 3 | It is linked to the next-pagination package in the parent directory for development purposes. 4 | 5 | You can run `npm install` and then `npm dev` to test your package. 6 | -------------------------------------------------------------------------------- /example/next.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | const withSourceMaps = require('@zeit/next-source-maps')() 3 | 4 | const isProd = (process.env.NODE_ENV || 'production') === 'production' 5 | 6 | module.exports = withSourceMaps({ 7 | assetPrefix: isProd ? '/next-pagination' : undefined, 8 | basePath: isProd ? '/next-pagination' : undefined 9 | }) 10 | -------------------------------------------------------------------------------- /example/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-pagination-example", 3 | "homepage": "https://etchteam.github.io/next-pagination", 4 | "version": "0.0.0", 5 | "private": true, 6 | "dependencies": { 7 | "@zeit/next-source-maps": "0.0.4-canary.1", 8 | "next": "^13.5.4", 9 | "@etchteam/next-pagination": "file:..", 10 | "node-sass": "9.0.0", 11 | "react": "file:../node_modules/react", 12 | "react-dom": "file:../node_modules/react-dom" 13 | }, 14 | "scripts": { 15 | "dev": "next", 16 | "build": "next build && next export -o build", 17 | "start": "next start" 18 | }, 19 | "browserslist": [ 20 | ">0.2%", 21 | "not dead", 22 | "not ie <= 11", 23 | "not op_mini all" 24 | ] 25 | } 26 | -------------------------------------------------------------------------------- /example/pages/[dynamic].js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Pagination from '@etchteam/next-pagination/dist' 3 | 4 | export default function Dynamic() { 5 | return ( 6 |
7 |

Dynamic Pagination

8 |

9 | This page demonstrates pagination working with dynamic urls. Feel free 10 | to change the url to '/whatever-you-like' and see that the pagination 11 | retains the url. 12 |

13 | 14 | 15 |
16 | ) 17 | } 18 | -------------------------------------------------------------------------------- /example/pages/_app.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Head from 'next/head' 3 | 4 | import '@etchteam/next-pagination/dist/index.css' 5 | import '../styles/main.css' 6 | 7 | function MyApp({ Component, pageProps }) { 8 | return ( 9 |
10 | 11 | Next pagination 12 | 13 | 14 |
15 | ) 16 | } 17 | 18 | export default MyApp 19 | -------------------------------------------------------------------------------- /example/pages/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Link from 'next/link' 3 | 4 | import Pagination from '@etchteam/next-pagination/dist' 5 | 6 | import theme from '../styles/theme.module.scss' 7 | 8 | export default function Home() { 9 | return ( 10 |
11 |

Next Pagination

12 |

13 | A semantic, accessible, responsive, robust, pagination component for 14 | sites built with Next.js. 15 |

16 | 17 |

Why use this pagination module?

18 | 19 |
    20 |
  • 21 | Accessible. Semantic HTML and fully marked up with 22 | appropriate aria roles for assisted browsing. 23 |
  • 24 |
  • 25 | Usable. The base CSS styles account for keyboard 26 | focus states and fat finger touch targets. 27 |
  • 28 |
  • 29 | Responsive. Works on all devices. 30 |
  • 31 |
  • 32 | Themeable. Make it look however you want. 33 |
  • 34 |
  • 35 | Self contained. There's only one required prop to get 36 | going. The rest of the logic is handled for you. 37 |
  • 38 |
  • 39 | Works with Next. Integrated with the Next.js router. 40 |
  • 41 |
42 | 43 |

Default theme

44 | 45 | 46 |

Custom theme

47 | 48 | 49 |

Custom page sizes

50 | 51 | 52 |

Custom labels

53 | 58 | 59 |

Other examples

60 |

61 | 62 | Dynamic pagination 63 | 64 |

65 |
66 | ) 67 | } 68 | -------------------------------------------------------------------------------- /example/public/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etchteam/next-pagination/55b8901fa2429dadab560db1c3e7f625f45babd8/example/public/example.png -------------------------------------------------------------------------------- /example/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/etchteam/next-pagination/55b8901fa2429dadab560db1c3e7f625f45babd8/example/public/favicon.ico -------------------------------------------------------------------------------- /example/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 16 | 17 | 18 | 27 | best-damn-pagination 28 | 29 | 30 | 31 | 34 | 35 |
36 | 37 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /example/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "next-pagination", 3 | "name": "next-pagination", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /example/styles/main.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | html, 6 | body { 7 | background: #edeef2; 8 | font-family: sans-serif; 9 | line-height: 1.5; 10 | } 11 | 12 | main { 13 | margin: auto; 14 | max-width: 50em; 15 | padding: 5%; 16 | } 17 | -------------------------------------------------------------------------------- /example/styles/theme.module.scss: -------------------------------------------------------------------------------- 1 | $next-pagination-interative-color: #006dcc; 2 | 3 | $next-pagination-spacing-vertical: 1em; 4 | $next-pagination-spacing-horizontal: 1em; 5 | 6 | $next-pagination-spacing-vertical-sm: $next-pagination-spacing-vertical / 2; 7 | $next-pagination-spacing-horizontal-sm: $next-pagination-spacing-horizontal / 2; 8 | 9 | $next-pagination-border-width: 1px; 10 | $next-pagination-border-radius: 4px; 11 | $next-pagination-line-height: 24px; 12 | 13 | $next-pagination-item-background: #fff; 14 | $next-pagination-item-background-current: #f0f0eb; 15 | $next-pagination-item-background-disabled: $next-pagination-item-background; 16 | 17 | $next-pagination-item-color: $next-pagination-interative-color; 18 | $next-pagination-item-color-current: #4f4f4f; 19 | $next-pagination-item-color-disabled: $next-pagination-item-color-current; 20 | 21 | $next-pagination-item-border-color: #d1c7bd; 22 | 23 | $next-pagination-select-background: #fff; 24 | $next-pagination-select-border-color: $next-pagination-item-border-color; 25 | $next-pagination-select-border-color-hover: $next-pagination-interative-color; 26 | 27 | .next-pagination { 28 | align-items: center; 29 | box-sizing: border-box; 30 | display: flex; 31 | flex-direction: row-reverse; 32 | justify-content: space-between; 33 | line-height: $next-pagination-line-height; 34 | user-select: none; 35 | 36 | * { 37 | box-sizing: inherit; 38 | } 39 | 40 | &__list { 41 | display: flex; 42 | list-style-type: none; 43 | margin: 0; 44 | padding: 0; 45 | } 46 | 47 | &__item { 48 | border: $next-pagination-border-width solid $next-pagination-item-border-color; 49 | border-left-color: transparent; 50 | display: none; 51 | margin-right: -$next-pagination-border-width; 52 | 53 | @media screen and (min-width: 37.5em) { 54 | display: block; 55 | } 56 | 57 | &:first-child, 58 | &:last-child { 59 | display: block; 60 | } 61 | 62 | &:first-child { 63 | border-left-color: $next-pagination-item-border-color; 64 | border-radius: $next-pagination-border-radius 0 0 $next-pagination-border-radius; 65 | 66 | .next-pagination__link { 67 | border-radius: $next-pagination-border-radius 0 0 $next-pagination-border-radius; 68 | } 69 | } 70 | 71 | &:last-child { 72 | border-radius: 0 $next-pagination-border-radius $next-pagination-border-radius 0; 73 | border-right-width: $next-pagination-border-width; 74 | margin-right: 0; 75 | 76 | .next-pagination__link { 77 | border-radius: 0 $next-pagination-border-radius $next-pagination-border-radius 0; 78 | } 79 | } 80 | 81 | &--hellip { 82 | min-width: 2.5em; 83 | padding: $next-pagination-spacing-vertical-sm $next-pagination-spacing-horizontal-sm; 84 | text-align: center; 85 | } 86 | } 87 | 88 | &__link { 89 | background: $next-pagination-item-background; 90 | color: $next-pagination-item-color; 91 | display: block; 92 | min-width: 2.5em; 93 | outline: $next-pagination-border-width solid transparent; 94 | padding: $next-pagination-spacing-vertical-sm $next-pagination-spacing-horizontal-sm; 95 | text-align: center; 96 | text-decoration: none; 97 | transition: outline-color .2s ease-in-out; 98 | 99 | &:hover, 100 | &:focus { 101 | outline: $next-pagination-border-width solid currentColor; 102 | position: relative; 103 | z-index: 1; 104 | } 105 | 106 | &--disabled { 107 | background: $next-pagination-item-background-disabled; 108 | color: $next-pagination-item-color-disabled; 109 | pointer-events: none; 110 | } 111 | 112 | &--current { 113 | background: $next-pagination-item-background-current; 114 | color: $next-pagination-item-color-current; 115 | pointer-events: none; 116 | } 117 | 118 | svg { 119 | display: block; 120 | } 121 | } 122 | 123 | &__form { 124 | align-items: center; 125 | display: flex; 126 | flex-direction: row-reverse; 127 | } 128 | 129 | &__label { 130 | flex: 0 0 auto; 131 | margin-left: $next-pagination-spacing-horizontal-sm; 132 | } 133 | 134 | &__select { 135 | background-color: $next-pagination-select-background; 136 | border: $next-pagination-border-width solid $next-pagination-select-border-color; 137 | border-radius: $next-pagination-border-radius; 138 | color: inherit; 139 | display: block; 140 | font-size: 1em; 141 | line-height: $next-pagination-line-height; 142 | position: relative; 143 | text-overflow: ellipsis; 144 | transition: border-color .2s ease-in-out; 145 | width: 100%; 146 | 147 | &:focus-within, 148 | &:hover { 149 | border-color: $next-pagination-select-border-color-hover; 150 | outline: none; 151 | } 152 | 153 | select { 154 | appearance: none; 155 | background: transparent; 156 | border: 0 none; 157 | display: block; 158 | height: calc(#{$next-pagination-spacing-vertical-sm * 2} + #{$next-pagination-line-height} + #{$next-pagination-border-width * 2}); 159 | font-size: 1em; 160 | line-height: $next-pagination-line-height; 161 | padding: $next-pagination-spacing-vertical-sm $next-pagination-spacing-horizontal-sm; 162 | padding-right: 2.5em; 163 | text-indent: $next-pagination-spacing-horizontal-sm; 164 | text-overflow: ellipsis; 165 | transition: border-color .2s ease-in-out; 166 | width: 100%; 167 | z-index: 1; 168 | 169 | &:focus { 170 | outline: none; 171 | } 172 | } 173 | 174 | select::-ms-expand { 175 | display: none; 176 | } 177 | 178 | &-suffix { 179 | position: absolute; 180 | right: 0; 181 | top: 0; 182 | height: 100%; 183 | pointer-events: none; 184 | width: 2em; 185 | 186 | svg { 187 | display: block; 188 | height: 16px; 189 | left: 50%; 190 | position: relative; 191 | top: 50%; 192 | transform: translate(-50%, -50%); 193 | width: 16px; 194 | } 195 | } 196 | } 197 | 198 | // SR only 199 | &__submit { 200 | border: 0; 201 | clip: rect(0 0 0 0); 202 | clip: rect(0, 0, 0, 0); 203 | height: 1px; 204 | margin: -1px; 205 | overflow: hidden; 206 | padding: 0; 207 | position: absolute; 208 | width: 1px; 209 | } 210 | } 211 | 212 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@etchteam/next-pagination", 3 | "version": "3.5.5", 4 | "description": "Pagination component for Next.js apps", 5 | "author": "etchteam", 6 | "license": "MIT", 7 | "repository": "etchteam/next-pagination", 8 | "main": "dist/index.js", 9 | "module": "dist/index.modern.js", 10 | "source": "src/index.tsx", 11 | "engines": { 12 | "node": ">=10" 13 | }, 14 | "scripts": { 15 | "build": "microbundle-crl --no-compress --format modern,cjs", 16 | "start": "microbundle-crl watch --no-compress --format modern,cjs", 17 | "prepare": "run-s build", 18 | "test": "run-s test:types test:unit", 19 | "test:unit": "cross-env CI=1 react-scripts test --env=jsdom", 20 | "test:watch": "react-scripts test --env=jsdom", 21 | "test:types": "tsc", 22 | "predeploy": "cd example && npm install && npm run build && touch build/.nojekyll", 23 | "deploy": "gh-pages -t -d example/build" 24 | }, 25 | "peerDependencies": { 26 | "react": ">=16", 27 | "next": ">=10" 28 | }, 29 | "dependencies": { 30 | "classnames": "^2.3.1", 31 | "lodash": "4.17.21", 32 | "query-string": "6.12.1" 33 | }, 34 | "devDependencies": { 35 | "@types/jest": "^27.5.0", 36 | "@types/lodash": "^4.14.182", 37 | "@types/node": "^17.0.31", 38 | "@types/react": "^18.0.8", 39 | "@typescript-eslint/eslint-plugin": "^5.22.0", 40 | "@typescript-eslint/parser": "^5.22.0", 41 | "babel-eslint": "^10.0.3", 42 | "cross-env": "^7.0.2", 43 | "eslint": "^7.1.0", 44 | "eslint-config-prettier": "^6.7.0", 45 | "eslint-plugin-import": "^2.26.0", 46 | "eslint-plugin-prettier": "^3.1.1", 47 | "eslint-plugin-react": "^7.17.0", 48 | "fibers": "5.0.0", 49 | "gh-pages": "^5.0.0", 50 | "microbundle-crl": "^0.13.8", 51 | "next": "^14.2.15", 52 | "node-sass": "9.0.0", 53 | "npm-run-all": "^4.1.5", 54 | "prettier": "^2.0.4", 55 | "react": "^18.2.0", 56 | "react-dom": "^18.2.0", 57 | "react-scripts": "^5.0.1", 58 | "sass": "1.26.5", 59 | "typescript": "^4.6.4" 60 | }, 61 | "files": [ 62 | "dist" 63 | ], 64 | "publishConfig": { 65 | "access": "public" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/components/Icon.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | // Icons from: https://material.io/resources/icons/?style=round 5 | 6 | type IconName = 'chevron-left' | 'chevron-right' | 'expand-more' 7 | 8 | function path(icon: IconName) { 9 | switch (icon) { 10 | case 'chevron-left': 11 | return ( 12 | 13 | 14 | 18 | 19 | ) 20 | case 'chevron-right': 21 | return ( 22 | 23 | 24 | 28 | 29 | ) 30 | case 'expand-more': 31 | return ( 32 | 33 | 34 | 38 | 39 | ) 40 | default: 41 | return '' 42 | } 43 | } 44 | 45 | export default function Icon({ icon }: { icon: IconName }) { 46 | return ( 47 | 56 | ) 57 | } 58 | 59 | Icon.propTypes = { 60 | icon: PropTypes.string.isRequired 61 | } 62 | -------------------------------------------------------------------------------- /src/components/Item.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | interface ItemProps { 4 | children: React.ReactNode 5 | theme: { [key: string]: any } 6 | [key: string]: any 7 | } 8 | 9 | export default function Item({ children, theme }: ItemProps) { 10 | return
  • {children}
  • 11 | } 12 | -------------------------------------------------------------------------------- /src/components/Link.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import classNames from 'classnames' 3 | 4 | interface LinkProps { 5 | children: React.ReactNode 6 | label: string 7 | theme: { [key: string]: any } 8 | current?: boolean 9 | disabled?: boolean 10 | [key: string]: any 11 | } 12 | 13 | export default class Link extends React.Component { 14 | render() { 15 | const { children, current, disabled, label, theme, ...props } = this.props 16 | const cx = classNames(theme['next-pagination__link'], { 17 | [`${theme['next-pagination__link--current']}`]: current, 18 | [`${theme['next-pagination__link--disabled']}`]: disabled 19 | }) 20 | 21 | return ( 22 | 23 | {children} 24 | 25 | ) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/components/List.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | 4 | interface ListProps { 5 | children: React.ReactNode 6 | theme: { [key: string]: any } 7 | } 8 | 9 | export default function List({ children, theme }: ListProps) { 10 | return
      {children}
    11 | } 12 | -------------------------------------------------------------------------------- /src/components/Select.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import Icon from './Icon' 4 | 5 | interface SelectProps { 6 | children: React.ReactNode 7 | theme: { [key: string]: any } 8 | [key: string]: any 9 | } 10 | 11 | const Select = ({ children, theme, ...props }: SelectProps) => ( 12 |
    13 | 14 | 15 | 16 | 17 |
    18 | ) 19 | 20 | export default Select 21 | -------------------------------------------------------------------------------- /src/declarations.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.scss' 2 | -------------------------------------------------------------------------------- /src/index.module.scss: -------------------------------------------------------------------------------- 1 | $next-pagination-interative-color: #72256d; 2 | 3 | $next-pagination-spacing-vertical: 1em; 4 | $next-pagination-spacing-horizontal: 1em; 5 | 6 | $next-pagination-spacing-vertical-sm: $next-pagination-spacing-vertical * 0.5; 7 | $next-pagination-spacing-horizontal-sm: $next-pagination-spacing-horizontal * 0.5; 8 | 9 | $next-pagination-border-width: 1px; 10 | $next-pagination-border-radius: 4px; 11 | $next-pagination-line-height: 24px; 12 | 13 | $next-pagination-item-background: #fff; 14 | $next-pagination-item-background-current: #f7f8fa; 15 | $next-pagination-item-background-disabled: $next-pagination-item-background; 16 | 17 | $next-pagination-item-color: $next-pagination-interative-color; 18 | $next-pagination-item-color-current: #666; 19 | $next-pagination-item-color-disabled: $next-pagination-item-color-current; 20 | 21 | $next-pagination-item-border-color: #edeef2; 22 | 23 | $next-pagination-select-background: #fff; 24 | $next-pagination-select-border-color: $next-pagination-item-border-color; 25 | $next-pagination-select-border-color-hover: $next-pagination-interative-color; 26 | 27 | .next-pagination { 28 | align-items: center; 29 | box-sizing: border-box; 30 | display: flex; 31 | flex-direction: row-reverse; 32 | justify-content: space-between; 33 | line-height: $next-pagination-line-height; 34 | user-select: none; 35 | 36 | * { 37 | box-sizing: inherit; 38 | } 39 | 40 | &__list { 41 | display: flex; 42 | list-style-type: none; 43 | margin: 0; 44 | padding: 0; 45 | } 46 | 47 | &__item { 48 | border: $next-pagination-border-width solid $next-pagination-item-border-color; 49 | border-left-color: transparent; 50 | display: none; 51 | margin-right: -$next-pagination-border-width; 52 | 53 | @media screen and (min-width: 37.5em) { 54 | display: block; 55 | } 56 | 57 | &:first-child, 58 | &:last-child { 59 | display: block; 60 | } 61 | 62 | &:first-child { 63 | border-left-color: $next-pagination-item-border-color; 64 | border-radius: $next-pagination-border-radius 0 0 $next-pagination-border-radius; 65 | 66 | .next-pagination__link { 67 | border-radius: $next-pagination-border-radius 0 0 $next-pagination-border-radius; 68 | } 69 | } 70 | 71 | &:last-child { 72 | border-radius: 0 $next-pagination-border-radius $next-pagination-border-radius 0; 73 | border-right-width: $next-pagination-border-width; 74 | margin-right: 0; 75 | 76 | .next-pagination__link { 77 | border-radius: 0 $next-pagination-border-radius $next-pagination-border-radius 0; 78 | } 79 | } 80 | 81 | &--hellip { 82 | min-width: 2.5em; 83 | padding: $next-pagination-spacing-vertical-sm $next-pagination-spacing-horizontal-sm; 84 | text-align: center; 85 | } 86 | } 87 | 88 | &__link { 89 | background: $next-pagination-item-background; 90 | color: $next-pagination-item-color; 91 | display: block; 92 | min-width: 2.5em; 93 | outline: $next-pagination-border-width solid transparent; 94 | padding: $next-pagination-spacing-vertical-sm $next-pagination-spacing-horizontal-sm; 95 | text-align: center; 96 | text-decoration: none; 97 | transition: outline-color .2s ease-in-out; 98 | 99 | &:hover, 100 | &:focus { 101 | outline: $next-pagination-border-width solid currentColor; 102 | position: relative; 103 | z-index: 1; 104 | } 105 | 106 | &--disabled { 107 | background: $next-pagination-item-background-disabled; 108 | color: $next-pagination-item-color-disabled; 109 | pointer-events: none; 110 | } 111 | 112 | &--current { 113 | background: $next-pagination-item-background-current; 114 | color: $next-pagination-item-color-current; 115 | pointer-events: none; 116 | } 117 | 118 | svg { 119 | display: block; 120 | } 121 | } 122 | 123 | &__form { 124 | align-items: center; 125 | display: flex; 126 | flex-direction: row-reverse; 127 | } 128 | 129 | &__label { 130 | flex: 0 0 auto; 131 | margin-left: $next-pagination-spacing-horizontal-sm; 132 | } 133 | 134 | &__select { 135 | background-color: $next-pagination-select-background; 136 | border: $next-pagination-border-width solid $next-pagination-select-border-color; 137 | border-radius: $next-pagination-border-radius; 138 | color: inherit; 139 | display: block; 140 | font-size: 1em; 141 | line-height: $next-pagination-line-height; 142 | position: relative; 143 | text-overflow: ellipsis; 144 | transition: border-color .2s ease-in-out; 145 | width: 100%; 146 | 147 | &:focus-within, 148 | &:hover { 149 | border-color: $next-pagination-select-border-color-hover; 150 | outline: none; 151 | } 152 | 153 | select { 154 | appearance: none; 155 | background: transparent; 156 | border: 0 none; 157 | display: block; 158 | height: calc(#{$next-pagination-spacing-vertical-sm * 2} + #{$next-pagination-line-height} + #{$next-pagination-border-width * 2}); 159 | font-size: 1em; 160 | line-height: $next-pagination-line-height; 161 | padding: $next-pagination-spacing-vertical-sm $next-pagination-spacing-horizontal-sm; 162 | padding-right: 2.5em; 163 | text-indent: $next-pagination-spacing-horizontal-sm; 164 | text-overflow: ellipsis; 165 | transition: border-color .2s ease-in-out; 166 | width: 100%; 167 | z-index: 1; 168 | 169 | &:focus { 170 | outline: none; 171 | } 172 | } 173 | 174 | select::-ms-expand { 175 | display: none; 176 | } 177 | 178 | &-suffix { 179 | position: absolute; 180 | right: 0; 181 | top: 0; 182 | height: 100%; 183 | pointer-events: none; 184 | width: 2em; 185 | 186 | svg { 187 | display: block; 188 | height: 16px; 189 | left: 50%; 190 | position: relative; 191 | top: 50%; 192 | transform: translate(-50%, -50%); 193 | width: 16px; 194 | } 195 | } 196 | } 197 | 198 | // SR only 199 | &__submit { 200 | border: 0; 201 | clip: rect(0 0 0 0); 202 | clip: rect(0, 0, 0, 0); 203 | height: 1px; 204 | margin: -1px; 205 | overflow: hidden; 206 | padding: 0; 207 | position: absolute; 208 | width: 1px; 209 | } 210 | } 211 | 212 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import Pagination from '.' 2 | import { getSizes } from './lib/sizes' 3 | import assert from 'assert' 4 | 5 | describe('Pagination', () => { 6 | it('is truthy', () => { 7 | expect(Pagination).toBeTruthy() 8 | }) 9 | }) 10 | 11 | // NOTE: [20,40,60,80,100] <= default sizes 12 | describe('Custom Sizes', () => { 13 | it('should return an array', () => { 14 | // second argument is the default value 15 | assert.deepStrictEqual(getSizes(), [20, 40, 60, 80, 100]) 16 | assert.deepStrictEqual(getSizes([10, 20, 10]), [10, 20]) 17 | }) 18 | it('should return default sizes', () => { 19 | // will return defaults 20 | assert.deepStrictEqual(getSizes([]), [20, 40, 60, 80, 100]) 21 | assert.deepStrictEqual( 22 | getSizes(['', 10, 20] as any[]), 23 | [20, 40, 60, 80, 100] 24 | ) 25 | assert.deepStrictEqual(getSizes([{}, {}] as any[]), [20, 40, 60, 80, 100]) 26 | }) 27 | }) 28 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { EventHandler, useEffect, useState } from 'react' 2 | import { useRouter } from 'next/router' 3 | import NextLink from 'next/link' 4 | import Head from 'next/head' 5 | import queryString from 'query-string' 6 | import pickBy from 'lodash/pickBy' 7 | import isEmpty from 'lodash/isEmpty' 8 | 9 | import List from './components/List' 10 | import Item from './components/Item' 11 | import Link from './components/Link' 12 | import Icon from './components/Icon' 13 | import Select from './components/Select' 14 | 15 | import { getSizes } from './lib/sizes' 16 | import { getPageNumbers } from './lib/get-page-numbers' 17 | 18 | import defaultTheme from './index.module.scss' 19 | 20 | interface PaginationProps { 21 | /** 22 | * The total number of pages 23 | */ 24 | total: number 25 | /** 26 | * A CSS modules style object 27 | */ 28 | theme?: { [key: string]: any } 29 | /** 30 | * An array of page size numbers 31 | */ 32 | sizes?: number[] 33 | /** 34 | * Label for the page size dropdown 35 | */ 36 | perPageText?: string 37 | /** 38 | * Label for the invisible page size button 39 | */ 40 | setPageSizeText?: string 41 | /** 42 | * Extra props to pass to the next.js links 43 | */ 44 | linkProps?: { [key: string]: any } 45 | } 46 | 47 | const Pagination = ({ 48 | total, 49 | theme, 50 | sizes, 51 | perPageText, 52 | setPageSizeText, 53 | linkProps 54 | }: PaginationProps) => { 55 | const styles = theme || defaultTheme 56 | const router = useRouter() 57 | const [hasRouter, setHasRouter] = useState(false) 58 | useEffect(() => { 59 | setHasRouter(true) 60 | }, [router]) 61 | 62 | if (!hasRouter) return null 63 | const query = pickBy({ ...(router.query || {}) }, (q) => !isEmpty(q)) 64 | const currentPage = Number(query.page || 1) 65 | // default|custom => evaluated sizes 66 | const cSizes = getSizes(sizes) 67 | const pageSize = Number(query.size) || cSizes[0] 68 | const isLastPage = currentPage * pageSize >= total 69 | const pageNumbers = getPageNumbers({ currentPage, pageSize, total }) 70 | 71 | const path = router.pathname 72 | 73 | const url = (page: number | string) => 74 | `?${queryString.stringify({ 75 | ...query, 76 | page 77 | })}` 78 | 79 | return ( 80 | 197 | ) 198 | } 199 | 200 | Pagination.defaultProps = { 201 | total: 0, 202 | perPageText: 'per page', 203 | setPageSizeText: 'Set page size', 204 | sizes: undefined, 205 | linkProps: {} 206 | } 207 | 208 | export default Pagination 209 | -------------------------------------------------------------------------------- /src/lib/get-page-numbers.ts: -------------------------------------------------------------------------------- 1 | interface GetPageNumbersArgs { 2 | currentPage: number 3 | pageSize: number 4 | total: number 5 | pageNumbersToShow?: number 6 | } 7 | 8 | export const getPageNumbers = ({ 9 | currentPage, 10 | pageSize, 11 | total, 12 | pageNumbersToShow = 3 13 | }: GetPageNumbersArgs) => { 14 | const lastPageNumber = Math.ceil(total / pageSize) 15 | const currentPageNumber = 16 | currentPage <= lastPageNumber ? currentPage : lastPageNumber 17 | const maxPagesBeforeCurrentPage = Math.floor(pageNumbersToShow / 2) 18 | const maxPagesAfterCurrentPage = Math.ceil(pageNumbersToShow / 2) - 1 19 | let startPage = 1 20 | let endPage = lastPageNumber 21 | 22 | if (lastPageNumber <= 1) { 23 | return [] // Don't show numbers if there's only 1 page 24 | } 25 | 26 | if (currentPageNumber <= maxPagesBeforeCurrentPage) { 27 | // near the start 28 | startPage = 1 29 | endPage = pageNumbersToShow 30 | } else if (currentPageNumber + maxPagesAfterCurrentPage >= lastPageNumber) { 31 | // near the end 32 | startPage = lastPageNumber - pageNumbersToShow + 1 33 | } else { 34 | // somewhere in the middle 35 | startPage = currentPageNumber - maxPagesBeforeCurrentPage 36 | endPage = currentPageNumber + maxPagesAfterCurrentPage 37 | } 38 | 39 | let pageNumbers: (string | number)[] = Array.from( 40 | Array(endPage + 1 - startPage).keys() 41 | ) 42 | .map((pageNumber) => startPage + pageNumber) 43 | .filter((pageNumber) => pageNumber <= lastPageNumber && pageNumber > 0) 44 | 45 | if (pageNumbers[0] > 1) { 46 | if (pageNumbers[0] <= 2) { 47 | pageNumbers = [1, ...pageNumbers] 48 | } else { 49 | const ellipsis = pageNumbers[0] > 3 ? '...' : 2 50 | pageNumbers = [1, ellipsis, ...pageNumbers] 51 | } 52 | } 53 | 54 | if (pageNumbers[pageNumbers.length - 1] !== lastPageNumber) { 55 | if (pageNumbers[pageNumbers.length - 1] === lastPageNumber - 1) { 56 | pageNumbers = [...pageNumbers, lastPageNumber] 57 | } else { 58 | const ellipsis = 59 | pageNumbers[pageNumbers.length - 1] < lastPageNumber - 2 60 | ? '...' 61 | : lastPageNumber - 1 62 | pageNumbers = [...pageNumbers, ellipsis, lastPageNumber] 63 | } 64 | } 65 | 66 | return pageNumbers 67 | } 68 | -------------------------------------------------------------------------------- /src/lib/sizes.ts: -------------------------------------------------------------------------------- 1 | import isInteger from 'lodash/isInteger' 2 | import every from 'lodash/every' 3 | import uniq from 'lodash/uniq' 4 | 5 | export const getSizes = (customSizes?: number[]) => { 6 | const defaultSizes = [20, 40, 60, 80, 100] 7 | // only if customSizes is an array & all values are valid 8 | if (Array.isArray(customSizes) && every(customSizes, isInteger)) { 9 | const uniqSizes = uniq(customSizes).sort((a, b) => a - b) 10 | // if the evaluate array is empty, return default 11 | // otherwise, return evaluated values 12 | if (!uniqSizes.length) return defaultSizes 13 | return uniqSizes 14 | } 15 | // default 16 | return defaultSizes 17 | } 18 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": [ 7 | "dom", 8 | "dom.iterable", 9 | "esnext" 10 | ], 11 | "jsx": "preserve", 12 | "baseUrl": "./src", 13 | "declaration": true, 14 | "noEmit": true, 15 | "strict": true, 16 | "esModuleInterop": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "allowJs": true, 19 | "skipLibCheck": true, 20 | "allowSyntheticDefaultImports": true, 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "types": ["jest", "node"] 24 | }, 25 | "exclude": [ 26 | "node_modules" 27 | ], 28 | "include": [ 29 | "**/*.ts", 30 | "**/*.tsx" 31 | ] 32 | } 33 | --------------------------------------------------------------------------------