├── .eslintrc ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg ├── pre-commit └── pre-push ├── .npmignore ├── .nvmrc ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-interactive-tools.cjs └── releases │ └── yarn-2.4.2.cjs ├── .yarnrc.yml ├── CONTRIBUTING.md ├── LICENSE ├── MIGRATIONS.md ├── README.md ├── babel.config.js ├── commitlint.config.js ├── jest.config.js ├── jest.setup.js ├── package.json ├── rollup.config.js ├── scripts └── test-build.sh ├── src ├── index.test.js └── index.tsx ├── tsconfig.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": ["@typescript-eslint"], 5 | "env": { "jasmine": true }, 6 | "rules": { 7 | "no-use-before-define": "off", 8 | "@typescript-eslint/no-use-before-define": ["error"], 9 | "object-curly-newline": 0, 10 | "react/destructuring-assignment": 0, 11 | "react/jsx-one-expression-per-line": 0, 12 | "react/jsx-filename-extension": 0, 13 | "react/no-multi-comp": 0, 14 | "react/jsx-props-no-spreading": 0 15 | }, 16 | "globals": { 17 | "jest": "writeable" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [15.14.0] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - name: yarn install 28 | uses: borales/actions-yarn@v2.1.0 29 | with: 30 | cmd: install 31 | - name: yarn build 32 | uses: borales/actions-yarn@v2.1.0 33 | with: 34 | cmd: build 35 | - name: yarn lint 36 | uses: borales/actions-yarn@v2.1.0 37 | with: 38 | cmd: lint 39 | - name: yarn types 40 | uses: borales/actions-yarn@v2.1.0 41 | with: 42 | cmd: types 43 | - name: yarn test 44 | uses: borales/actions-yarn@v2.1.0 45 | with: 46 | cmd: test 47 | - name: yarn test-build 48 | uses: borales/actions-yarn@v2.1.0 49 | with: 50 | cmd: test-build 51 | - name: Coveralls 52 | uses: coverallsapp/github-action@master 53 | with: 54 | github-token: ${{ secrets.GITHUB_TOKEN }} 55 | env: 56 | CI: true 57 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist/* 3 | /yarn-error.log 4 | /coverage 5 | .yarn/* 6 | !.yarn/patches 7 | !.yarn/releases 8 | !.yarn/plugins 9 | !.yarn/sdks 10 | !.yarn/versions 11 | .pnp.* 12 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn build && yarn lint && yarn test 5 | -------------------------------------------------------------------------------- /.husky/pre-push: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | yarn build && yarn types && yarn lint && yarn test-build 5 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .eslintrc 2 | .git/ 3 | .github/ 4 | .gitignore 5 | .gitignore 6 | .husky/ 7 | .npmignore 8 | .nvmrc 9 | .yarn/ 10 | .yarnrc.yml 11 | CONTRIBUTING.md 12 | MIGRATIONS.md 13 | babel.config.js 14 | commitlint.config.js 15 | coverage/ 16 | hooks/ 17 | jest.config.js 18 | jest.setup.js 19 | rollup.config.js 20 | scripts/ 21 | src/ 22 | tsconfig.json 23 | yarn-error.log 24 | yarn.lock 25 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v15.14.0 2 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-interactive-tools.cjs 5 | spec: "@yarnpkg/plugin-interactive-tools" 6 | 7 | yarnPath: .yarn/releases/yarn-2.4.2.cjs 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | =============================================================================== 3 | 4 | Have something to add? Feature requests, bug reports, and contributions are 5 | enormously welcome! 6 | 7 | 1. Fork this repo 8 | 2. Update the tests and implement the change 9 | 3. Submit a [pull request][github-pull-request] 10 | 11 | (hint: following the conventions in the [the code review 12 | checklist][code-review-checklist] will expedite review and merge) 13 | 14 | [github-pull-request]: help.github.com/pull-requests/ 15 | [code-review-checklist]: https://github.com/rjz/code-review-checklist 16 | 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Justin Schrader 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MIGRATIONS.md: -------------------------------------------------------------------------------- 1 | ## Migrating from 2.x.x -> 3.x.x 2 | 3 | #### First things, first... 4 | 5 | `withBreadcrumbs` now returns an array of `Object`s instead of `Component`s: 6 | 7 | ```diff 8 | - breadcrumbs.map(breadcrumb) 9 | + breadcrumbs.map({ breadcrumb }) 10 | ``` 11 | 12 | Within this object, other props like `match`, `location`, and pass-through props are also returned: 13 | 14 | ```diff 15 | - breadcrumbs.map((breadcrumb) => {}) 16 | + breadcrumbs.map(({ breadcrumb, match, location, someCustomProp }) => {}) 17 | ``` 18 | 19 | #### Why was this change made? 20 | 21 | Under the hood, `withBreadcrumbs` uses React's `createElement` method to render breadcrumbs. In version 2, all props (like `match`, `location`, etc) were assigned to the rendered component (for example: `createElement(breadcrumb, componentProps);`). 22 | 23 | This had the unintended side-effect of rendering any of these props as an _attribute_ on the DOM element. So, ultimately this resulted in some breadcrumbs rendering like `'` as well as some React console warnings [in certain cases](https://github.com/icd2k3/react-router-breadcrumbs-hoc/issues/59). 24 | 25 | This issue has been solved by adding the following logic: 26 | - If the breadcrumb is a simple string, don't render it with props applied 27 | - If the breadcrumb is a function/class (dynamic), _then_ pass all the props to it 28 | - Return objects instead of components so that we can still utilize all the props during the `map` 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | LEGACY 3 |

4 |

5 | This repository is for legacy support of react-router v5.

6 | Please use use-react-router-breadcrumbs and react-router v6 instead. 7 |

8 | 9 |
10 | 11 |

12 | React Router Breadcrumbs HOC 13 |

14 | 15 |

16 | 17 | 18 | 19 |

20 | 21 |

22 | A small (~1.3kb compressed & gzipped), flexible, higher order component for rendering breadcrumbs with react-router 5 23 |

24 | 25 |
26 |

27 | 28 | example.com/user/123 → Home / User / John Doe 29 | 30 |

31 |
32 | 33 | 34 | 35 | - [Description](#description) 36 | - [Features](#features) 37 | - [Install](#install) 38 | - [Usage](#usage) 39 | - [Examples](#examples) 40 | - [Simple](#simple) 41 | - [Advanced](#advanced) 42 | - [Route config compatibility](#route-config-compatibility) 43 | - [Dynamic breadcrumbs](#dynamic-breadcrumbs) 44 | - [Options](#options) 45 | - [Disabling default generated breadcrumbs](#disabling-default-generated-breadcrumbs) 46 | - [Order matters!](#order-matters) 47 | - [API](#api) 48 | 49 | 50 | 51 | ## Description 52 | 53 | Render breadcrumbs for `react-router` however you want! 54 | 55 | #### Features 56 | - Easy to get started with automatically generated breadcrumbs. 57 | - Render, map, and wrap breadcrumbs any way you want. 58 | - Compatible with existing [route configs](https://reacttraining.com/react-router/web/example/route-config). 59 | 60 | ## Install 61 | 62 | `yarn add react-router-breadcrumbs-hoc` 63 | 64 | or 65 | 66 | `npm i react-router-breadcrumbs-hoc --save` 67 | 68 | ## Usage 69 | 70 | ```js 71 | withBreadcrumbs()(MyComponent); 72 | ``` 73 | 74 | ## Examples 75 | 76 | ### Simple 77 | Start seeing generated breadcrumbs right away with this simple example ([codesandbox](https://codesandbox.io/s/bare-bones-example-kcdrt)) 78 | ```js 79 | import withBreadcrumbs from 'react-router-breadcrumbs-hoc'; 80 | 81 | const Breadcrumbs = ({ breadcrumbs }) => ( 82 | <> 83 | {breadcrumbs.map(({ breadcrumb }) => breadcrumb)} 84 | 85 | ) 86 | 87 | export default withBreadcrumbs()(Breadcrumbs); 88 | ``` 89 | 90 | ### Advanced 91 | The example above will work for some routes, but you may want other routes to be dynamic (such as a user name breadcrumb). Let's modify it to handle custom-set breadcrumbs. ([codesandbox](https://codesandbox.io/s/basic-dynamic-example-m03tz)) 92 | 93 | ```js 94 | import withBreadcrumbs from 'react-router-breadcrumbs-hoc'; 95 | 96 | const userNamesById = { '1': 'John' } 97 | 98 | const DynamicUserBreadcrumb = ({ match }) => ( 99 | {userNamesById[match.params.userId]} 100 | ); 101 | 102 | // define custom breadcrumbs for certain routes. 103 | // breadcumbs can be components or strings. 104 | const routes = [ 105 | { path: '/users/:userId', breadcrumb: DynamicUserBreadcrumb }, 106 | { path: '/example', breadcrumb: 'Custom Example' }, 107 | ]; 108 | 109 | // map, render, and wrap your breadcrumb components however you want. 110 | const Breadcrumbs = ({ breadcrumbs }) => ( 111 |
112 | {breadcrumbs.map(({ 113 | match, 114 | breadcrumb 115 | }) => ( 116 | 117 | {breadcrumb} 118 | 119 | ))} 120 |
121 | ); 122 | 123 | export default withBreadcrumbs(routes)(Breadcrumbs); 124 | ``` 125 | 126 | For the above example... 127 | 128 | Pathname | Result 129 | --- | --- 130 | /users | Home / Users 131 | /users/1 | Home / Users / John 132 | /example | Home / Custom Example 133 | 134 | ## [Route config](https://reacttraining.com/react-router/web/example/route-config) compatibility 135 | 136 | Add breadcrumbs to your existing [route config](https://reacttraining.com/react-router/web/example/route-config). This is a great way to keep all routing config paths in a single place! If a path ever changes, you'll only have to change it in your main route config rather than maintaining a _separate_ config for `react-router-breadcrumbs-hoc`. 137 | 138 | For example... 139 | 140 | ```js 141 | const routeConfig = [ 142 | { 143 | path: "/sandwiches", 144 | component: Sandwiches 145 | } 146 | ]; 147 | ``` 148 | 149 | becomes... 150 | 151 | ```js 152 | const routeConfig = [ 153 | { 154 | path: "/sandwiches", 155 | component: Sandwiches, 156 | breadcrumb: 'I love sandwiches' 157 | } 158 | ]; 159 | ``` 160 | 161 | then you can just pass the whole route config right into the hook: 162 | 163 | ```js 164 | withBreadcrumbs(routeConfig)(MyComponent); 165 | ``` 166 | 167 | ## Dynamic breadcrumbs 168 | 169 | If you pass a component as the `breadcrumb` prop it will be injected with react-router's [match](https://reacttraining.com/react-router/web/api/match) and [location](https://reacttraining.com/react-router/web/api/location) objects as props. These objects contain ids, hashes, queries, etc from the route that will allow you to map back to whatever you want to display in the breadcrumb. 170 | 171 | Let's use Redux as an example with the [match](https://reacttraining.com/react-router/web/api/match) object: 172 | 173 | ```js 174 | // UserBreadcrumb.jsx 175 | const PureUserBreadcrumb = ({ firstName }) => {firstName}; 176 | 177 | // find the user in the store with the `id` from the route 178 | const mapStateToProps = (state, props) => ({ 179 | firstName: state.userReducer.usersById[props.match.params.id].firstName, 180 | }); 181 | 182 | export default connect(mapStateToProps)(PureUserBreadcrumb); 183 | 184 | // routes = [{ path: '/users/:id', breadcrumb: UserBreadcrumb }] 185 | // example.com/users/123 --> Home / Users / John 186 | ``` 187 | 188 | Now we can pass this custom `redux` breadcrumb into the HOC: 189 | 190 | ```js 191 | withBreadcrumbs([{ 192 | path: '/users/:id', 193 | breadcrumb: UserBreadcrumb 194 | }]); 195 | ``` 196 | 197 | ---- 198 | 199 | Similarly, the [location](https://reacttraining.com/react-router/web/api/location) object could be useful for displaying dynamic breadcrumbs based on the route's state: 200 | 201 | ```jsx 202 | // dynamically update EditorBreadcrumb based on state info 203 | const EditorBreadcrumb = ({ location: { state: { isNew } } }) => ( 204 | {isNew ? 'Add New' : 'Update'} 205 | ); 206 | 207 | // routes = [{ path: '/editor', breadcrumb: EditorBreadcrumb }] 208 | 209 | // upon navigation, breadcrumb will display: Update 210 | Edit 211 | 212 | // upon navigation, breadcrumb will display: Add New 213 | Add 214 | ``` 215 | 216 | ## Options 217 | 218 | An options object can be passed as the 2nd argument to the hook. 219 | 220 | ```js 221 | withBreadcrumbs(routes, options)(Component); 222 | ``` 223 | 224 | Option | Type | Description 225 | --- | --- | --- 226 | `disableDefaults` | `Boolean` | Disables all default generated breadcrumbs. | 227 | `excludePaths` | `Array` | Disables default generated breadcrumbs for specific paths. | 228 | 229 | ### Disabling default generated breadcrumbs 230 | 231 | This package will attempt to create breadcrumbs for you based on the route section. For example `/users` will automatically create the breadcrumb `"Users"`. There are two ways to disable default breadcrumbs for a path: 232 | 233 | **Option 1:** Disable _all_ default breadcrumb generation by passing `disableDefaults: true` in the `options` object 234 | 235 | `withBreadcrumbs(routes, { disableDefaults: true })` 236 | 237 | **Option 2:** Disable _individual_ default breadcrumbs by passing `breadcrumb: null` in route config: 238 | 239 | `{ path: '/a/b', breadcrumb: null }` 240 | 241 | **Option 3:** Disable _individual_ default breadcrumbs by passing an `excludePaths` array in the `options` object 242 | 243 | `withBreadcrumbs(routes, { excludePaths: ['/', '/no-breadcrumb/for-this-route'] })` 244 | 245 | ## Order matters! 246 | 247 | Consider the following route configs: 248 | 249 | ```js 250 | [ 251 | { path: '/users/:id', breadcrumb: 'id-breadcrumb' }, 252 | { path: '/users/create', breadcrumb: 'create-breadcrumb' }, 253 | ] 254 | 255 | // example.com/users/create = 'id-breadcrumb' (because path: '/users/:id' will match first) 256 | // example.com/users/123 = 'id-breadcumb' 257 | ``` 258 | 259 | To fix the issue above, just adjust the order of your routes: 260 | 261 | ```js 262 | [ 263 | { path: '/users/create', breadcrumb: 'create-breadcrumb' }, 264 | { path: '/users/:id', breadcrumb: 'id-breadcrumb' }, 265 | ] 266 | 267 | // example.com/users/create = 'create-breadcrumb' (because path: '/users/create' will match first) 268 | // example.com/users/123 = 'id-breadcrumb' 269 | ``` 270 | 271 | ## API 272 | 273 | ```js 274 | Route = { 275 | path: String 276 | breadcrumb?: String|Component // if not provided, a default breadcrumb will be returned 277 | matchOptions?: { // see: https://reacttraining.com/react-router/web/api/matchPath 278 | exact?: Boolean, 279 | strict?: Boolean, 280 | } 281 | } 282 | 283 | Options = { 284 | excludePaths?: string[] // disable default breadcrumb generation for specific paths 285 | disableDefaults?: Boolean // disable all default breadcrumb generation 286 | } 287 | 288 | // if routes are not passed, default breadcrumbs will be returned 289 | withBreadcrumbs(routes?: Route[], options?: Options): HigherOrderComponent 290 | 291 | // you shouldn't ever really have to use `getBreadcrumbs`, but it's 292 | // exported for convenience if you don't want to use the HOC 293 | getBreadcrumbs({ 294 | routes: Route[], 295 | options: Options, 296 | }): Breadcrumb[] 297 | ``` 298 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | 3 | module.exports = function(api) { 4 | api.cache(true); 5 | 6 | return { 7 | presets: [ 8 | '@babel/preset-env', 9 | '@babel/preset-react', 10 | '@babel/preset-typescript' 11 | ], 12 | plugins: ['@babel/plugin-transform-runtime'], 13 | }; 14 | }; 15 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { extends: ['@commitlint/config-conventional'] }; 2 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | collectCoverage: true, 3 | coverageDirectory: './coverage', 4 | coveragePathIgnorePatterns: [ 5 | '/coverage/', 6 | '/dist/', 7 | '/node_modules/', 8 | 'jest.config.js', 9 | 'jest.setup.js', 10 | ], 11 | coverageThreshold: { 12 | global: { 13 | branches: 100, 14 | functions: 100, 15 | lines: 100, 16 | statements: 100, 17 | }, 18 | }, 19 | setupFiles: ['./jest.setup.js'], 20 | }; 21 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import 'jsdom-global/register'; 2 | import Enzyme from 'enzyme'; 3 | import Adapter from '@wojtekmaj/enzyme-adapter-react-17'; 4 | 5 | Enzyme.configure({ adapter: new Adapter() }); 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-router-breadcrumbs-hoc", 3 | "version": "4.1.0", 4 | "description": "small, flexible, higher order component for rendering breadcrumbs with react-router 4.x", 5 | "repository": "icd2k3/react-router-breadcrumbs-hoc", 6 | "main": "dist/cjs/index.js", 7 | "module": "dist/es/index.js", 8 | "umd": "dist/umd/index.js", 9 | "types": "dist/index.d.ts", 10 | "scripts": { 11 | "prepublishOnly": "npm run build && pinst --disable", 12 | "build": "rollup -c && yarn types", 13 | "test": "jest", 14 | "test-build": "sh ./scripts/test-build.sh", 15 | "types": "tsc -p tsconfig.json --declaration --emitDeclarationOnly", 16 | "lint": "eslint ./src/**", 17 | "postpublish": "pinst --enable", 18 | "prepare": "husky install" 19 | }, 20 | "author": "Justin Schrader (me@justin.beer)", 21 | "license": "MIT", 22 | "peerDependencies": { 23 | "react": ">=16.8", 24 | "react-router-dom": ">=5" 25 | }, 26 | "devDependencies": { 27 | "@babel/cli": "^7.14.5", 28 | "@babel/core": "^7.14.5", 29 | "@babel/plugin-transform-runtime": "^7.14.5", 30 | "@babel/preset-env": "^7.14.5", 31 | "@babel/preset-react": "^7.14.5", 32 | "@babel/preset-typescript": "^7.14.5", 33 | "@commitlint/cli": "^12.1.4", 34 | "@commitlint/config-conventional": "^12.1.4", 35 | "@rollup/plugin-babel": "^5.3.0", 36 | "@rollup/plugin-typescript": "^8.2.1", 37 | "@types/react": "^17.0.11", 38 | "@types/react-dom": "^17.0.7", 39 | "@types/react-router-dom": "^5.3.0", 40 | "@typescript-eslint/eslint-plugin": "^4.26.1", 41 | "@typescript-eslint/parser": "^4.26.1", 42 | "@wojtekmaj/enzyme-adapter-react-17": "^0.6.1", 43 | "babel-eslint": "^10.1.0", 44 | "coveralls": "^3.1.0", 45 | "enzyme": "^3.11.0", 46 | "eslint": "^7.28.0", 47 | "eslint-config-airbnb": "^18.2.1", 48 | "eslint-plugin-import": "^2.23.4", 49 | "eslint-plugin-jsx-a11y": "^6.4.1", 50 | "eslint-plugin-react": "^7.24.0", 51 | "husky": "^6.0.0", 52 | "jest": "^27.0.4", 53 | "jsdom": "^16.6.0", 54 | "jsdom-global": "^3.0.2", 55 | "pinst": "^2.1.6", 56 | "prop-types": "^15.7.2", 57 | "react": "17.0.2", 58 | "react-dom": "17.0.2", 59 | "react-router-dom": "^5.3.0", 60 | "rollup": "^2.51.1", 61 | "rollup-plugin-size": "^0.2.2", 62 | "rollup-plugin-terser": "^7.0.2", 63 | "typescript": "4.3.2" 64 | }, 65 | "keywords": [ 66 | "react", 67 | "router", 68 | "breadcrumbs", 69 | "react-router", 70 | "react-router 4", 71 | "react-router 5", 72 | "react-router-dom" 73 | ] 74 | } 75 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | import typescript from '@rollup/plugin-typescript'; 3 | import size from 'rollup-plugin-size'; 4 | import { terser } from 'rollup-plugin-terser'; 5 | 6 | const pkg = require('./package.json'); 7 | 8 | const external = Object.keys(pkg.peerDependencies).concat(/@babel\/runtime/); 9 | 10 | const extensions = ['.js', '.tsx']; 11 | 12 | const sharedPlugins = [ 13 | typescript({ tsconfig: './tsconfig.json' }), 14 | babel({ 15 | babelHelpers: 'runtime', 16 | exclude: 'node_modules/**', 17 | extensions, 18 | }), 19 | size(), 20 | ]; 21 | 22 | const formats = [ 23 | { format: 'umd', file: pkg.umd, plugins: sharedPlugins.concat([terser({ format: { comments: false } })]) }, 24 | { format: 'cjs', file: pkg.main, plugins: sharedPlugins }, 25 | { format: 'es', file: pkg.module, plugins: sharedPlugins }, 26 | ]; 27 | 28 | const globals = { 29 | react: 'React', 30 | 'react-router-dom': 'ReactRouterDom', 31 | }; 32 | 33 | export default formats.map(({ plugins, file, format }) => ({ 34 | input: 'src/index.tsx', 35 | plugins, 36 | external, 37 | output: { 38 | exports: 'named', 39 | file, 40 | format, 41 | name: 'react-router-breadcrumbs-hoc', 42 | globals: format !== 'umd' 43 | ? globals 44 | : { 45 | ...globals, 46 | '@babel/runtime/helpers/toConsumableArray': '_toConsumableArray', 47 | '@babel/runtime/helpers/defineProperty': '_defineProperty', 48 | '@babel/runtime/helpers/objectWithoutProperties': '_objectWithoutProperties', 49 | }, 50 | }, 51 | })); 52 | -------------------------------------------------------------------------------- /scripts/test-build.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | # runs the tests in ./src/index.test.js, but 4 | # replaces the import to target the compiled builds 5 | # in ./dist/es/index.js, ./dist/umd/index.js, and ./dist/cjs/index.js 6 | # this ensures that the act of compiling doesn't break the 7 | # expected behavior somehow. 8 | 9 | set -e 10 | 11 | printf "\n====\nTesting CJS dist build\n====\n" && \ 12 | TEST_BUILD=cjs yarn test --coverage=0 && \ 13 | printf "\n====\nTesting UMD dist build\n====\n" && \ 14 | TEST_BUILD=umd yarn test --coverage=0 && \ 15 | printf "\n====\nTesting ES dist build\n====\n" && \ 16 | TEST_BUILD=es yarn test --coverage=0 17 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | /* eslint-disable react/no-array-index-key */ 3 | /* eslint-disable react/jsx-filename-extension */ 4 | import React from 'react'; 5 | import PropTypes from 'prop-types'; 6 | import { mount } from 'enzyme'; 7 | import { MemoryRouter as Router, NavLink } from 'react-router-dom'; 8 | import withBreadcrumbs, { getBreadcrumbs } from './index.tsx'; 9 | 10 | // imports to test compiled builds 11 | import withBreadcrumbsCompiledES, { getBreadcrumbs as getBreadcrumbsCompiledES } from '../dist/es/index'; 12 | import withBreadcrumbsCompiledUMD, { getBreadcrumbs as getBreadcrumbsCompiledUMD } from '../dist/umd/index'; 13 | import withBreadcrumbsCompiledCJS, { getBreadcrumbs as getBreadcrumbsCompiledCJS } from '../dist/cjs/index'; 14 | 15 | const components = { 16 | Breadcrumbs: ({ breadcrumbs, ...forwardedProps }) => ( 17 |

18 |
19 | {forwardedProps && Object.values(forwardedProps).filter((v) => typeof v === 'string').map((value) => ( 20 | {value} 21 | ))} 22 |
23 |
24 | {breadcrumbs.map(({ breadcrumb, key }, index) => ( 25 | 26 | {breadcrumb} 27 | {(index < breadcrumbs.length - 1) && / } 28 | 29 | ))} 30 |
31 |

32 | ), 33 | BreadcrumbMatchTest: ({ match }) => {match.params.number}, 34 | BreadcrumbNavLinkTest: ({ match }) => Link, 35 | BreadcrumbLocationTest: ({ location: { state: { isLocationTest } } }) => ( 36 | 37 | {isLocationTest ? 'pass' : 'fail'} 38 | 39 | ), 40 | BreadcrumbExtraPropsTest: ({ foo, bar }) => {foo}{bar}, 41 | BreadcrumbMemoized: React.memo(() => Memoized), 42 | // eslint-disable-next-line react/prefer-stateless-function 43 | BreadcrumbClass: class BreadcrumbClass extends React.PureComponent { 44 | render() { return (Class); } 45 | }, 46 | }; 47 | 48 | const getHOC = () => { 49 | switch (process.env.TEST_BUILD) { 50 | case 'cjs': 51 | return withBreadcrumbsCompiledCJS; 52 | case 'umd': 53 | return withBreadcrumbsCompiledUMD; 54 | case 'es': 55 | return withBreadcrumbsCompiledES; 56 | default: 57 | return withBreadcrumbs; 58 | } 59 | }; 60 | 61 | const getMethod = () => { 62 | switch (process.env.TEST_BUILD) { 63 | case 'cjs': 64 | return getBreadcrumbsCompiledCJS; 65 | case 'umd': 66 | return getBreadcrumbsCompiledUMD; 67 | case 'es': 68 | return getBreadcrumbsCompiledES; 69 | default: 70 | return getBreadcrumbs; 71 | } 72 | }; 73 | 74 | const render = ({ 75 | options, 76 | pathname, 77 | routes, 78 | state, 79 | props, 80 | }) => { 81 | const Breadcrumbs = getHOC()(routes, options)(components.Breadcrumbs); 82 | const wrapper = mount( 83 | 87 | 88 | , 89 | ); 90 | 91 | return { 92 | breadcrumbs: wrapper.find('.breadcrumbs-container').text(), 93 | forwardedProps: wrapper.find('.forwarded-props').text(), 94 | wrapper, 95 | }; 96 | }; 97 | 98 | const matchShape = { 99 | isExact: PropTypes.bool.isRequired, 100 | params: PropTypes.shape().isRequired, 101 | path: PropTypes.string.isRequired, 102 | url: PropTypes.string.isRequired, 103 | }; 104 | 105 | components.Breadcrumbs.propTypes = { 106 | breadcrumbs: PropTypes.arrayOf(PropTypes.shape({ 107 | breadcrumb: PropTypes.node.isRequired, 108 | match: PropTypes.shape().isRequired, 109 | location: PropTypes.shape.isRequired, 110 | })).isRequired, 111 | }; 112 | 113 | components.BreadcrumbMatchTest.propTypes = { 114 | match: PropTypes.shape(matchShape).isRequired, 115 | }; 116 | 117 | components.BreadcrumbNavLinkTest.propTypes = { 118 | match: PropTypes.shape(matchShape).isRequired, 119 | }; 120 | 121 | components.BreadcrumbLocationTest.propTypes = { 122 | location: PropTypes.shape({ 123 | state: PropTypes.shape({ 124 | isLocationTest: PropTypes.bool.isRequired, 125 | }).isRequired, 126 | }).isRequired, 127 | }; 128 | 129 | components.BreadcrumbExtraPropsTest.propTypes = { 130 | foo: PropTypes.string.isRequired, 131 | bar: PropTypes.string.isRequired, 132 | }; 133 | 134 | describe('react-router-breadcrumbs-hoc', () => { 135 | describe('Valid routes', () => { 136 | it('Should render breadcrumb components as expected', () => { 137 | const routes = [ 138 | // test home route 139 | { path: '/', breadcrumb: 'Home' }, 140 | // test breadcrumb passed as string 141 | { path: '/1', breadcrumb: 'One' }, 142 | // test simple breadcrumb component 143 | { path: '/1/2', breadcrumb: () => TWO }, 144 | // test advanced breadcrumb component (user can use `match` however they wish) 145 | { path: '/1/2/:number', breadcrumb: components.BreadcrumbMatchTest }, 146 | // test NavLink wrapped breadcrumb 147 | { path: '/1/2/:number/4', breadcrumb: components.BreadcrumbNavLinkTest }, 148 | // test a no-match route 149 | { path: '/no-match', breadcrumb: 'no match' }, 150 | ]; 151 | const { breadcrumbs, wrapper } = render({ pathname: '/1/2/3/4', routes }); 152 | expect(breadcrumbs).toBe('Home / One / TWO / 3 / Link'); 153 | expect(wrapper.find('NavLink').props().to).toBe('/1/2/3/4'); 154 | }); 155 | }); 156 | 157 | describe('Route order', () => { 158 | it('Should match the first breadcrumb in route array user/create', () => { 159 | const routes = [ 160 | { 161 | path: '/user/create', 162 | breadcrumb: 'Add User', 163 | }, 164 | { 165 | path: '/user/:id', 166 | breadcrumb: '1', 167 | }, 168 | ]; 169 | const { breadcrumbs } = render({ pathname: '/user/create', routes }); 170 | expect(breadcrumbs).toBe('Home / User / Add User'); 171 | }); 172 | 173 | it('Should match the first breadcrumb in route array user/:id', () => { 174 | const routes = [ 175 | { 176 | path: '/user/:id', 177 | breadcrumb: 'Oops', 178 | }, 179 | { 180 | path: '/user/create', 181 | breadcrumb: 'Add User', 182 | }, 183 | ]; 184 | const { breadcrumbs } = render({ pathname: '/user/create', routes }); 185 | expect(breadcrumbs).toBe('Home / User / Oops'); 186 | }); 187 | }); 188 | 189 | describe('Different component types', () => { 190 | it('Should render memoized components', () => { 191 | const routes = [{ path: '/memo', breadcrumb: components.BreadcrumbMemoized }]; 192 | const { breadcrumbs } = render({ pathname: '/memo', routes }); 193 | expect(breadcrumbs).toBe('Home / Memoized'); 194 | }); 195 | 196 | it('Should render class components', () => { 197 | const routes = [{ path: '/class', breadcrumb: components.BreadcrumbClass }]; 198 | const { breadcrumbs } = render({ pathname: '/class', routes }); 199 | expect(breadcrumbs).toBe('Home / Class'); 200 | }); 201 | }); 202 | 203 | describe('Custom match options', () => { 204 | it('Should allow `strict` rule', () => { 205 | const routes = [ 206 | { 207 | path: '/one/', 208 | breadcrumb: '1', 209 | // not recommended, but supported 210 | matchOptions: { exact: false, strict: true }, 211 | }, 212 | ]; 213 | const { breadcrumbs } = render({ pathname: '/one', routes }); 214 | expect(breadcrumbs).toBe(''); 215 | }); 216 | }); 217 | 218 | describe('When extending react-router config', () => { 219 | it('Should render expected breadcrumbs with sensible defaults', () => { 220 | const routes = [ 221 | { path: '/one', breadcrumb: 'OneCustom' }, 222 | { path: '/one/two' }, 223 | ]; 224 | const { breadcrumbs } = render({ pathname: '/one/two', routes }); 225 | expect(breadcrumbs).toBe('Home / OneCustom / Two'); 226 | }); 227 | 228 | it('Should support nested routes', () => { 229 | const routes = [ 230 | { 231 | path: '/one', 232 | routes: [ 233 | { 234 | path: '/one/two', 235 | breadcrumb: 'TwoCustom', 236 | routes: [ 237 | { path: '/one/two/three', breadcrumb: 'ThreeCustom' }, 238 | ], 239 | }, 240 | ], 241 | }, 242 | ]; 243 | const { breadcrumbs } = render({ pathname: '/one/two/three', routes }); 244 | expect(breadcrumbs).toBe('Home / One / TwoCustom / ThreeCustom'); 245 | }); 246 | }); 247 | 248 | describe('Defaults', () => { 249 | describe('No routes array', () => { 250 | it('Should automatically render breadcrumbs with default strings', () => { 251 | const { breadcrumbs } = render({ pathname: '/one/two' }); 252 | 253 | expect(breadcrumbs).toBe('Home / One / Two'); 254 | }); 255 | }); 256 | 257 | describe('Override defaults', () => { 258 | it('Should render user-provided breadcrumbs where possible and use defaults otherwise', () => { 259 | const routes = [{ path: '/one', breadcrumb: 'Override' }]; 260 | const { breadcrumbs } = render({ pathname: '/one/two', routes }); 261 | 262 | expect(breadcrumbs).toBe('Home / Override / Two'); 263 | }); 264 | }); 265 | 266 | describe('No breadcrumb', () => { 267 | it('Should be possible to NOT render a breadcrumb', () => { 268 | const routes = [{ path: '/one', breadcrumb: null }]; 269 | const { breadcrumbs } = render({ pathname: '/one/two', routes }); 270 | 271 | expect(breadcrumbs).toBe('Home / Two'); 272 | }); 273 | 274 | it('Should be possible to NOT render a "Home" breadcrumb', () => { 275 | const routes = [{ path: '/', breadcrumb: null }]; 276 | const { breadcrumbs } = render({ pathname: '/one/two', routes }); 277 | 278 | expect(breadcrumbs).toBe('One / Two'); 279 | }); 280 | }); 281 | }); 282 | 283 | describe('When using the location object', () => { 284 | it('Should be provided in the rendered breadcrumb component', () => { 285 | const routes = [{ path: '/one', breadcrumb: components.BreadcrumbLocationTest }]; 286 | const { breadcrumbs } = render({ pathname: '/one', state: { isLocationTest: true }, routes }); 287 | expect(breadcrumbs).toBe('Home / pass'); 288 | }); 289 | }); 290 | 291 | describe('When pathname includes query params', () => { 292 | it('Should not render query breadcrumb', () => { 293 | const { breadcrumbs } = render({ pathname: '/one?mock=query' }); 294 | expect(breadcrumbs).toBe('Home / One'); 295 | }); 296 | }); 297 | 298 | describe('When pathname includes a trailing slash', () => { 299 | it('Should ignore the trailing slash', () => { 300 | const { breadcrumbs } = render({ pathname: '/one/' }); 301 | expect(breadcrumbs).toBe('Home / One'); 302 | }); 303 | }); 304 | 305 | describe('When using additional props inside routes', () => { 306 | it('Should pass through extra props to user-provided components', () => { 307 | const routes = [ 308 | { 309 | path: '/one', 310 | breadcrumb: components.BreadcrumbExtraPropsTest, 311 | foo: 'Pass through', 312 | bar: ' props', 313 | }, 314 | ]; 315 | const { breadcrumbs } = render({ pathname: '/one', routes }); 316 | expect(breadcrumbs).toBe('Home / Pass through props'); 317 | }); 318 | }); 319 | 320 | describe('Options', () => { 321 | describe('excludePaths', () => { 322 | it('Should not return breadcrumbs for specified paths', () => { 323 | const { breadcrumbs } = render({ pathname: '/one/two', options: { excludePaths: ['/', '/one'] } }); 324 | expect(breadcrumbs).toBe('Two'); 325 | }); 326 | 327 | it('Should work with url params', () => { 328 | const routes = [ 329 | { path: '/a' }, 330 | { path: '/a/:b' }, 331 | { path: '/a/:b/:c' }, 332 | ]; 333 | const { breadcrumbs } = render({ 334 | pathname: '/a/b/c', 335 | routes, 336 | options: { excludePaths: ['/a/:b', '/a'] }, 337 | }); 338 | expect(breadcrumbs).toBe('Home / C'); 339 | }); 340 | }); 341 | 342 | describe('options without routes array', () => { 343 | it('Should be able to set options without defining a routes array', () => { 344 | const { breadcrumbs } = render({ pathname: '/one/two', routes: null, options: { excludePaths: ['/', '/one'] } }); 345 | expect(breadcrumbs).toBe('Two'); 346 | }); 347 | }); 348 | 349 | describe('disableDefaults', () => { 350 | it('Should disable all default breadcrumb generation', () => { 351 | const routes = [{ path: '/one', breadcrumb: 'One' }, { path: '/one/two' }]; 352 | const { breadcrumbs } = render({ pathname: '/one/two', routes, options: { disableDefaults: true } }); 353 | 354 | expect(breadcrumbs).toBe('One'); 355 | }); 356 | }); 357 | }); 358 | 359 | describe('Invalid route object', () => { 360 | it('Should error if `path` is not provided', () => { 361 | expect(() => getMethod()({ routes: [{ breadcrumb: 'Yo' }], location: { pathname: '/1' } })) 362 | .toThrow('withBreadcrumbs: `path` must be provided in every route object'); 363 | }); 364 | }); 365 | 366 | describe('DOM rendering', () => { 367 | it('Should not render props as element attributes on breadcrumbs', () => { 368 | const { wrapper } = render({ pathname: '/one' }); 369 | expect(wrapper.html()).not.toContain('[object Object]'); 370 | }); 371 | }); 372 | 373 | describe('HOC prop forwarding', () => { 374 | it('Should allow for forwarding props to the wrapped component', () => { 375 | const props = { testing: 'prop forwarding works' }; 376 | const { forwardedProps } = render({ pathname: '/', props }); 377 | expect(forwardedProps).toEqual('prop forwarding works'); 378 | }); 379 | }); 380 | 381 | describe('Edge cases', () => { 382 | it('Should handle 2 slashes in a URL (site.com/sandwiches//tuna)', () => { 383 | const { breadcrumbs } = render({ pathname: '/sandwiches//tuna' }); 384 | expect(breadcrumbs).toBe('Home / Sandwiches / Tuna'); 385 | }); 386 | }); 387 | }); 388 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * This source code is licensed under the MIT license found in the 3 | * LICENSE file in the root directory of this source tree. 4 | * 5 | * This script exports a HOC that accepts a routes array of objects 6 | * and an options object. 7 | * 8 | * API: 9 | * 10 | * withBreadcrumbs( 11 | * routes?: Array, 12 | * options? Object, 13 | * ): HigherOrderComponent 14 | * 15 | * More Info: 16 | * 17 | * https://github.com/icd2k3/react-router-breadcrumbs-hoc 18 | * 19 | */ 20 | 21 | import React, { createElement } from 'react'; 22 | import { useLocation, matchPath } from 'react-router-dom'; 23 | 24 | const DEFAULT_MATCH_OPTIONS = { exact: true }; 25 | const NO_BREADCRUMB = 'NO_BREADCRUMB'; 26 | 27 | export interface Options { 28 | currentSection?: string; 29 | disableDefaults?: boolean; 30 | excludePaths?: string[]; 31 | pathSection?: string; 32 | } 33 | 34 | export interface Location { 35 | pathname: string 36 | } 37 | 38 | export interface MatchOptions { 39 | exact?: boolean; 40 | strict?: boolean; 41 | sensitive?: boolean; 42 | } 43 | 44 | export interface BreadcrumbsRoute { 45 | path: string; 46 | breadcrumb?: React.ComponentType | React.ElementType | string; 47 | matchOptions?: MatchOptions; 48 | routes?: BreadcrumbsRoute[]; 49 | [x: string]: any; 50 | } 51 | 52 | /** 53 | * This method was "borrowed" from https://stackoverflow.com/a/28339742 54 | * we used to use the humanize-string package, but it added a lot of bundle 55 | * size and issues with compilation. This 4-liner seems to cover most cases. 56 | */ 57 | const humanize = (str: string): string => str 58 | .replace(/^[\s_]+|[\s_]+$/g, '') 59 | .replace(/[_\s]+/g, ' ') 60 | .replace(/^[a-z]/, (m) => m.toUpperCase()); 61 | 62 | /** 63 | * Renders and returns the breadcrumb complete 64 | * with `match`, `location`, and `key` props. 65 | */ 66 | const render = ({ 67 | breadcrumb: Breadcrumb, 68 | match, 69 | location, 70 | ...rest 71 | }: { 72 | breadcrumb: React.ComponentType | string, 73 | match: { url: string }, 74 | location: Location 75 | }): { 76 | match: { url: string }, 77 | location: Location, 78 | key: string, 79 | breadcrumb: React.ReactNode 80 | } => { 81 | const componentProps = { match, location, key: match.url, ...rest }; 82 | 83 | return { 84 | ...componentProps, 85 | breadcrumb: typeof Breadcrumb === 'string' 86 | ? createElement('span', { key: componentProps.key }, Breadcrumb) 87 | : , 88 | }; 89 | }; 90 | 91 | /** 92 | * Small helper method to get a default breadcrumb if the user hasn't provided one. 93 | */ 94 | const getDefaultBreadcrumb = ({ 95 | currentSection, 96 | location, 97 | pathSection, 98 | }: { 99 | currentSection: string, 100 | location: Location, 101 | pathSection: string, 102 | }) => { 103 | const match = matchPath(pathSection, { ...DEFAULT_MATCH_OPTIONS, path: pathSection }) 104 | /* istanbul ignore next: this is hard to mock in jest :( */ 105 | || { url: 'not-found' }; 106 | 107 | return render({ 108 | breadcrumb: humanize(currentSection), 109 | match, 110 | location, 111 | }); 112 | }; 113 | 114 | /** 115 | * Loops through the route array (if provided) and returns either a 116 | * user-provided breadcrumb OR a sensible default (if enabled) 117 | */ 118 | const getBreadcrumbMatch = ({ 119 | currentSection, 120 | disableDefaults, 121 | excludePaths, 122 | location, 123 | pathSection, 124 | routes, 125 | }: { 126 | currentSection: string, 127 | disableDefaults?: boolean, 128 | excludePaths?: string[], 129 | location: { pathname: string }, 130 | pathSection: string, 131 | routes: BreadcrumbsRoute[] 132 | }) => { 133 | let breadcrumb; 134 | 135 | // Check the optional `excludePaths` option in `options` to see if the 136 | // current path should not include a breadcrumb. 137 | const getIsPathExcluded = (path: string) => matchPath(pathSection, { 138 | path, 139 | exact: true, 140 | strict: false, 141 | }); 142 | if (excludePaths && excludePaths.some(getIsPathExcluded)) { 143 | return NO_BREADCRUMB; 144 | } 145 | 146 | // Loop through the route array and see if the user has provided a custom breadcrumb. 147 | routes.some(({ breadcrumb: userProvidedBreadcrumb, matchOptions, path, ...rest }) => { 148 | if (!path) { 149 | throw new Error('withBreadcrumbs: `path` must be provided in every route object'); 150 | } 151 | 152 | const match = matchPath(pathSection, { ...(matchOptions || DEFAULT_MATCH_OPTIONS), path }); 153 | 154 | // If user passed breadcrumb: null OR custom match options to suppress a breadcrumb 155 | // we need to know NOT to add it to the matches array 156 | // see: `if (breadcrumb !== NO_BREADCRUMB)` below. 157 | if ((match && userProvidedBreadcrumb === null) || (!match && matchOptions)) { 158 | breadcrumb = NO_BREADCRUMB; 159 | return true; 160 | } 161 | 162 | if (match) { 163 | // This covers the case where a user may be extending their react-router route 164 | // config with breadcrumbs, but also does not want default breadcrumbs to be 165 | // automatically generated (opt-in). 166 | if (!userProvidedBreadcrumb && disableDefaults) { 167 | breadcrumb = NO_BREADCRUMB; 168 | return true; 169 | } 170 | 171 | breadcrumb = render({ 172 | // Although we have a match, the user may be passing their react-router config object 173 | // which we support. The route config object may not have a `breadcrumb` param specified. 174 | // If this is the case, we should provide a default via `humanize`. 175 | breadcrumb: userProvidedBreadcrumb || humanize(currentSection), 176 | match, 177 | location, 178 | ...rest, 179 | }); 180 | return true; 181 | } 182 | return false; 183 | }); 184 | 185 | // User provided a breadcrumb prop, or we generated one above. 186 | if (breadcrumb) { 187 | return breadcrumb; 188 | } 189 | 190 | // If there was no breadcrumb provided and user has disableDefaults turned on. 191 | if (disableDefaults) { 192 | return NO_BREADCRUMB; 193 | } 194 | 195 | // If the above conditionals don't fire, generate a default breadcrumb based on the path. 196 | return getDefaultBreadcrumb({ 197 | pathSection, 198 | // include a "Home" breadcrumb by default (can be overrode or disabled in config). 199 | currentSection: pathSection === '/' ? 'Home' : currentSection, 200 | location, 201 | }); 202 | }; 203 | 204 | /** 205 | * Splits the pathname into sections, then search for matches in the routes 206 | * a user-provided breadcrumb OR a sensible default. 207 | */ 208 | export const getBreadcrumbs = ( 209 | { 210 | routes, 211 | location, 212 | options = {}, 213 | }: { 214 | routes: BreadcrumbsRoute[], 215 | location: Location, 216 | options?: Options 217 | }, 218 | ): Array => { 219 | const matches:Array = []; 220 | const { pathname } = location; 221 | 222 | pathname 223 | .split('?')[0] 224 | // Split pathname into sections. 225 | .split('/') 226 | // Reduce over the sections and call `getBreadcrumbMatch()` for each section. 227 | .reduce((previousSection: string, currentSection: string, index: number) => { 228 | // Combine the last route section with the currentSection. 229 | // For example, `pathname = /1/2/3` results in match checks for 230 | // `/1`, `/1/2`, `/1/2/3`. 231 | const pathSection = !currentSection ? '/' : `${previousSection}/${currentSection}`; 232 | 233 | // Ignore trailing slash or double slashes in the URL 234 | if (pathSection === '/' && index !== 0) { 235 | return ''; 236 | } 237 | 238 | const breadcrumb = getBreadcrumbMatch({ 239 | currentSection, 240 | location, 241 | pathSection, 242 | routes, 243 | ...options, 244 | }); 245 | 246 | // Add the breadcrumb to the matches array 247 | // unless the user has explicitly passed. 248 | // { path: x, breadcrumb: null } to disable. 249 | if (breadcrumb !== NO_BREADCRUMB) { 250 | matches.push(breadcrumb); 251 | } 252 | 253 | return pathSection === '/' ? '' : pathSection; 254 | }, ''); 255 | 256 | return matches; 257 | }; 258 | 259 | /** 260 | * Takes a route array and recursively flattens it IF there are 261 | * nested routes in the config. 262 | */ 263 | const flattenRoutes = (routes: BreadcrumbsRoute[]) => (routes) 264 | .reduce((arr, route: BreadcrumbsRoute): BreadcrumbsRoute[] => { 265 | if (route.routes) { 266 | return arr.concat([route, ...flattenRoutes(route.routes)]); 267 | } 268 | return arr.concat(route); 269 | }, [] as BreadcrumbsRoute[]); 270 | 271 | /** 272 | * Accepts optional routes array and options and returns an array of 273 | * breadcrumbs. 274 | * 275 | * @example 276 | * import withBreadcrumbs from 'react-router-breadcrumbs-hoc'; 277 | * const Breadcrumbs = ({ breadcrumbs }) => ( 278 | * <>{breadcrumbs.map(({ breadcrumb }) => breadcrumb)} 279 | * ) 280 | * export default withBreadcrumbs()(Breadcrumbs); 281 | */ 282 | const withBreadcrumbs = ( 283 | routes?: BreadcrumbsRoute[], 284 | options?: Options, 285 | ) => ( 286 | Component: React.ComponentType<{ 287 | breadcrumbs: Array 288 | }>, 289 | ) => (props: any) => React.createElement(Component, { 290 | ...props, 291 | breadcrumbs: getBreadcrumbs({ 292 | options, 293 | routes: flattenRoutes(routes || []), 294 | location: useLocation(), 295 | }), 296 | }); 297 | 298 | export default withBreadcrumbs; 299 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "esModuleInterop": true, 5 | "forceConsistentCasingInFileNames": true, 6 | "jsx": "react", 7 | "module": "esnext", 8 | "moduleResolution": "node", 9 | "outDir": "dist", 10 | "removeComments": true, 11 | "strict": true, 12 | "target": "es6" 13 | }, 14 | "files": [ 15 | "src/index.tsx" 16 | ] 17 | } 18 | --------------------------------------------------------------------------------