├── .eslintrc.js ├── .github └── workflows │ └── main.yml ├── .gitignore ├── .nvmrc ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── renovate.json ├── src ├── index.test.tsx └── index.ts ├── stryker.conf.js ├── tsconfig.json └── yarn.lock /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: "@typescript-eslint/parser", 3 | parserOptions: { 4 | project: "./tsconfig.json", 5 | ecmaVersion: 2018, 6 | sourceType: "module" 7 | }, 8 | extends: [ 9 | "typed-fp", 10 | "agile-digital", 11 | ], 12 | env: { 13 | "jest/globals": true, 14 | es6: true, 15 | browser: true, 16 | }, 17 | plugins: [ 18 | "jest", 19 | "react", 20 | "sonarjs", 21 | "functional", 22 | "jsx-a11y", 23 | "react-hooks", 24 | "@typescript-eslint", 25 | "prettier", 26 | "total-functions" 27 | ], 28 | rules: {}, 29 | settings: { 30 | react: { 31 | version: "detect", 32 | }, 33 | }, 34 | }; 35 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: CI 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on push or pull request events but only for the master branch 8 | push: 9 | branches: [ master ] 10 | pull_request: 11 | branches: [ master ] 12 | 13 | # Allows you to run this workflow manually from the Actions tab 14 | workflow_dispatch: 15 | 16 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 17 | jobs: 18 | # This workflow contains a single job called "build" 19 | build: 20 | # The type of runner that the job will run on 21 | runs-on: ubuntu-latest 22 | env: 23 | STRYKER_DASHBOARD_API_KEY: ${{ secrets.STRYKER_DASHBOARD_API_KEY }} 24 | 25 | # Steps represent a sequence of tasks that will be executed as part of the job 26 | steps: 27 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 28 | - uses: actions/checkout@v3 29 | 30 | - run: yarn install --frozen-lockfile --non-interactive 31 | - run: yarn build 32 | - run: yarn lint 33 | - run: yarn type-coverage 34 | - run: yarn test 35 | - run: yarn stryker run 36 | - run: yarn codecov 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | coverage 2 | dist 3 | node_modules 4 | reports -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | node -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Daniel Nixon 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://github.com/oaf-project/oaf-react-router/actions/workflows/main.yml/badge.svg)](https://github.com/oaf-project/oaf-react-router/actions/workflows/main.yml) 2 | [![type-coverage](https://img.shields.io/badge/dynamic/json.svg?label=type-coverage&prefix=%E2%89%A5&suffix=%&query=$.typeCoverage.atLeast&uri=https%3A%2F%2Fraw.githubusercontent.com%2Foaf-project%2Foaf-react-router%2Fmaster%2Fpackage.json)](https://github.com/plantain-00/type-coverage) 3 | [![Codecov](https://img.shields.io/codecov/c/github/oaf-project/oaf-react-router.svg)](https://codecov.io/gh/oaf-project/oaf-react-router) 4 | [![Mutation testing badge](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Foaf-project%2Foaf-react-router%2Fmaster)](https://dashboard.stryker-mutator.io/reports/github.com/oaf-project/oaf-react-router/master) 5 | [![Known Vulnerabilities](https://snyk.io/test/github/oaf-project/oaf-react-router/badge.svg?targetFile=package.json)](https://snyk.io/test/github/oaf-project/oaf-react-router?targetFile=package.json) 6 | [![npm](https://img.shields.io/npm/v/oaf-react-router.svg)](https://www.npmjs.com/package/oaf-react-router) 7 | 8 | # Oaf React Router 9 | 10 | An accessible wrapper for [React Router](https://github.com/remix-run/react-router), built on [oaf-routing](https://github.com/oaf-project/oaf-routing). 11 | 12 | ## Compatibility 13 | 14 | | React Router | [history](https://www.npmjs.com/package/history) | Oaf React Router | 15 | |--------------|--------------------------------------------------|------------------| 16 | | 6.4+ | NA | 4.0.0 or higher | 17 | | 6.4+ with [redux-first-history](https://github.com/salvoravida/redux-first-history) | 5 | 3.0.1 | 18 | | 6.0 - 6.3 | 5 | 3.0.1 | 19 | | 5 | 4 | 2.1.1 | 20 | 21 | 22 | DOM-only (no React Native support). 23 | 24 | ## Features 25 | 26 | * Reset scroll and focus after PUSH and REPLACE navigation 27 | * Restore scroll and focus after POP navigation 28 | * Set the page title after navigation 29 | * Announce navigation to users of screen readers 30 | * Hash fragment support 31 | 32 | ### Reset scroll and focus after PUSH and REPLACE navigation 33 | 34 | [React Router historically does not reset the window's scroll position or the focused element](https://reacttraining.com/react-router/web/guides/scroll-restoration) after page navigation (although see this TODO https://github.com/oaf-project/oaf-react-router/issues/521). 35 | 36 | The React Router documentation sketched a ["scroll to top" approach](https://reacttraining.com/react-router/web/guides/scroll-restoration/scroll-to-top) that scrolled the window back to the top of the page after navigation, emulating native browser behavior. There are also packages to do this for you, such as [trevorr/react-scroll-manager](https://github.com/trevorr/react-scroll-manager) or [react-router-scroll-top](https://github.com/bluframe/react-router-scroll-top/issues/10). Unfortunately, these approaches address only the scroll half of the question, [ignoring keyboard focus](http://simplyaccessible.com/article/spangular-accessibility/#acc-heading-3): 37 | 38 | > One of the unique features of single page applications that can create challenges for people using screen readers is that there’s never a page refresh, only view refreshes. As a result, the focused element often disappears from the interface, and the person using the screen reader is left searching for clues as to what happened and what’s now showing in the application view. Places where focus is commonly lost include: page changes, item deleting, modal closing, and expanding and closing record details. 39 | 40 | Oaf React Router fixes this by moving focus to something it calls the "primary focus target" after navigation, which by default is the first `h1` element inside the page's `main` element, but this is configurable. For advice on what this focus target should be, see [Marcy Sutton's recommendations](https://www.gatsbyjs.org/blog/2019-07-11-user-testing-accessible-client-routing/#recommendations-finding-common-ground). 41 | 42 | In addition to moving focus, Oaf React Router will also scroll the primary focus target into view, so you don't need to worry about scrolling to the top of the page after a page navigation. 43 | 44 | In a non-single page app website, a web browser will reset focus to the very top of the document after navigation (at the same time that it scrolls to top). You can emulate this with Oaf React Router by setting the primary focus target to `body` instead of the default `main h1`. 45 | 46 | See: 47 | * https://reacttraining.com/react-router/web/guides/scroll-restoration 48 | * https://github.com/ReactTraining/react-router/issues/5210 49 | * https://medium.com/@robdel12/single-page-apps-routers-are-broken-255daa310cf 50 | * https://www.gatsbyjs.org/blog/2019-07-11-user-testing-accessible-client-routing 51 | 52 | ### Restore scroll and focus after POP navigation 53 | 54 | After a POP navigation (i.e. after navigation back or forward through history) browsers typically restore focus and scroll position to where they were when the user last navigated away from that page. 55 | 56 | [React Router does not emulate this](https://reacttraining.com/react-router/web/guides/scroll-restoration/generic-solution), so Oaf React Router takes care of it for you. Note that browsers such as Firefox and Safari will restore _both_ scroll position and the last focused element, but for some reason Chrome restores _only_ the scroll position, not the focused element. We choose to emulate the focus-restoring behaviour by default. If you'd like to disable this restoration of the focused element after POP navigation, either globally or selectively (perhaps based on user agent sniffing), set the `restorePageStateOnPop` option to false. Note that doing so will disable scroll restorating as well as focus restorating, so make sure you have a separate solution for that in place. 57 | 58 | Note that there is a [proposed scroll restoration standard](https://majido.github.io/scroll-restoration-proposal/history-based-api.html) but it is not widely implemented and it only addresses scroll position, not focus (notice a theme emerging?) so it is of no use to us. 59 | 60 | See: 61 | * https://github.com/ReactTraining/react-router/issues/3950 62 | * https://developer.mozilla.org/de/docs/Web/API/History#Browser_compatibility 63 | * https://github.com/Fyrd/caniuse/issues/1889 64 | 65 | ### Set the page title after navigation 66 | 67 | [Every page in your React app must have a unique and descriptive title](https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-title.html). Oaf React Router will set the page title for you using a function that maps from `location`s to page titles. You must supply this function. For how to provide this function, see the usage section below. 68 | 69 | See: 70 | * https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-title.html 71 | 72 | ### Announce navigation to users of screen readers 73 | 74 | Oaf React Router will announce page navigation events to screen reader users via a [visually hidden](https://a11yproject.com/posts/how-to-hide-content/) [`aria-live`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/ARIA_Live_Regions) element. [Announcing navigation is required](https://almerosteyn.com/2017/03/accessible-react-navigation) because: 75 | 76 |

Screen readers are clever enough to read a lot of information that the browser expose naturally, but if no information exists to read out, the screen reader will remain ominously silent, even if something very important has happened on screen.

77 | 78 |

Unfortunately, this is the case with many routed SPA applications today. Screen readers are able to recognise actual browser navigation very easily as the browser will tell the screen reader that it has navigated to another web page. In the case of SPAs, like those built with React or Angular, the router software will take over some of the navigation actions from the browser in order to control the application without constantly reloading the host HTML page.

79 | 80 |

The result: A totally silent page transition leading to a very confusing experience for these users. Imagine trying to navigate a web application if you could not even see that the navigation was successful!

81 | 82 | By default, Oaf React Router will announce "navigated to foo" where "foo" is the page title returned by the function described in the previous section. You can override this to support localization, etc. 83 | 84 | See: 85 | * https://almerosteyn.com/2017/03/accessible-react-navigation 86 | 87 | ### Hash fragment support 88 | 89 | Another native browser feature that React Router doesn't emulate is scrolling to the element identified by the hash fragment in a URL. For example, if you load https://en.wikipedia.org/wiki/Firefox#Performance, your browser will scroll down to the `` automatically. 90 | 91 | There are other libraries that tackle this issue—for example [rafrex/react-router-hash-link](https://github.com/rafrex/react-router-hash-link)—but they typically only address scroll to the exclusion of focus (there's that theme again). 92 | 93 | Oaf React Router implements this for you, taking care of both focus and scroll. 94 | 95 | A [caveat](https://github.com/oaf-project/oaf-react-router/issues/8) here is that the identified element must exist in the DOM straight after the route is rendered. If the element won't exist for some time, e.g. until after an API response, then Oaf React Router won't focus or scroll to it, falling back on the primary focus target. 96 | 97 | ## Installation 98 | 99 | ```sh 100 | # yarn 101 | yarn add oaf-react-router 102 | 103 | # npm 104 | npm install oaf-react-router 105 | ``` 106 | 107 | ## Basic Usage 108 | 109 | ### React Router 5 110 | 111 | ```diff 112 | - import { BrowserRouter as Router } from "react-router-dom"; 113 | + import { Router } from "react-router-dom"; 114 | + import { createBrowserHistory } from "history"; 115 | + import { wrapHistory } from "oaf-react-router"; 116 | 117 | + const history = createBrowserHistory(); // or createHashHistory() 118 | + wrapHistory(history); 119 | 120 | ReactDOM.render(( 121 | - 122 | + 123 | ... 124 | 125 | ), document.getElementById("root")); 126 | ``` 127 | 128 | ### React Router 6.0 to 6.3 129 | 130 | ```diff 131 | - import { BrowserRouter } from "react-router-dom"; 132 | + import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom"; 133 | + import { createBrowserHistory } from "history"; 134 | + import { wrapHistory } from "oaf-react-router"; 135 | 136 | + const history = createBrowserHistory(); // or createHashHistory() 137 | + wrapHistory(history); 138 | 139 | ReactDOM.render(( 140 | - 141 | + 142 | ... 143 | - 144 | + 145 | ), document.getElementById("root")); 146 | ``` 147 | 148 | ### React Router 6.4+ with [redux-first-history](https://github.com/salvoravida/redux-first-history) 149 | 150 | _Stick to version 3.x.y of oaf-react-router._ 151 | 152 | React Router 6.0 to 6.3 used [history](https://www.npmjs.com/package/history) but 6.4 dropped it. Even with React Router 6.4, redux-first-history continues to use the `history` package. For this reason, redux-first-history provides its own `HistoryRouter`. See https://github.com/salvoravida/redux-first-history#usage. For these reasons, if you're using React Router 6.4+ with redux-first-history you can continue to use version 3.x.y of oaf-react-router (the last version to support `history`). You just need to use `redux-first-history`'s `HistoryRouter`, which boils down to doing the above, but replacing the line 153 | 154 | ```diff 155 | + import { unstable_HistoryRouter as HistoryRouter } from "react-router-dom"; 156 | ``` 157 | 158 | with the line 159 | 160 | ```diff 161 | + import { HistoryRouter } from "redux-first-history/rr6"; 162 | ``` 163 | 164 | See https://github.com/salvoravida/redux-first-history/issues/95 165 | 166 | ### React Router 6.4+ 167 | 168 | _Use version 4.0.0 or later of oaf-react-router._ 169 | 170 | As per https://github.com/remix-run/react-router/issues/9422#issuecomment-1302564759, with the addition of a call to `wrapRouter`. 171 | 172 | ```diff 173 | 174 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 175 | + import { wrapRouter } from "oaf-react-router"; 176 | 177 | const router = createBrowserRouter([ 178 | // match everything with "*" 179 | { path: "*", element: } 180 | ]) 181 | 182 | + wrapRouter(router); 183 | 184 | ReactDOM.createRoot(document.getElementById('root')).render( 185 | 186 | 187 | 188 | ) 189 | ``` 190 | 191 | Detailed examples are available in the tests: https://github.com/oaf-project/oaf-react-router/blob/master/src/index.test.tsx 192 | 193 | ## Advanced Usage 194 | 195 | ```typescript 196 | const history = createBrowserHistory(); 197 | 198 | const settings = { 199 | announcementsDivId: "announcements", 200 | primaryFocusTarget: "main h1, [role=main] h1", 201 | // This assumes you're setting the document title via some other means (e.g. React Helmet). 202 | // If you're not, you should return a unique and descriptive page title for each page 203 | // from this function and set `setPageTitle` to true. 204 | documentTitle: (location: Location) => document.title, 205 | // BYO localization 206 | navigationMessage: (title: string, location: Location, action: Action): string => `Navigated to ${title}.`, 207 | // Return false if you're handling focus yourself for a specific history action. 208 | shouldHandleAction: (previousLocation: Location, nextLocation: Location, action: Action) => true, 209 | disableAutoScrollRestoration: true, 210 | announcePageNavigation: true, 211 | setPageTitle: false, 212 | handleHashFragment: true, 213 | // Set this to false if you are using HashRouter or MemoryRouter. 214 | restorePageStateOnPop: true, 215 | // Set this to true for smooth scrolling. 216 | // For browser compatibility you might want iamdustan's smoothscroll polyfill https://github.com/iamdustan/smoothscroll 217 | smoothScroll: false, 218 | }; 219 | 220 | wrapHistory(history, settings); 221 | 222 | // Or wrapRouter(router, settings) 223 | ``` 224 | 225 | ### A note on setting document title 226 | 227 | You may already be using [React Helmet](https://github.com/nfl/react-helmet) or some other technique to set the document title on route change. That's fine, just be mindful of how you might announce page navigation to users of screen readers and other assistive technology. 228 | 229 | In the case of React Helmet, you might do something like this: 230 | 1. Set both `setPageTitle` and `announcePageNavigation` to `false` in the config object you pass to Oaf React Router's `wrapHistory` function. 231 | 2. Add a handler function to [React Helmet's `onChangeClientState` callback](https://github.com/nfl/react-helmet#reference-guide). 232 | 3. Announce page navigation using something like [the `announce` function from Oaf Side Effects](https://oaf-project.github.io/oaf-side-effects/modules/_index_.html#announce) (which is what Oaf React Router itself uses). 233 | 234 | ### A note on focus outlines 235 | You may see focus outlines around your `h1` elements (or elsewhere, per `primaryFocusTarget`) when using Oaf React Router. 236 | 237 | You might be tempted to remove these focus outlines with something like the following: 238 | ```css 239 | [tabindex="-1"]:focus { 240 | outline: 0 !important; 241 | } 242 | ``` 243 | 244 | Don't do this! Focus outlines are important for accessibility. See for example: 245 | 246 | * https://www.w3.org/TR/UNDERSTANDING-WCAG20/navigation-mechanisms-focus-visible.html 247 | * https://www.w3.org/TR/2016/NOTE-WCAG20-TECHS-20161007/F78 248 | * http://www.outlinenone.com/ 249 | * https://github.com/twbs/bootstrap/issues/28425 250 | * Although there is some debate: https://github.com/w3c/wcag/issues/1001 251 | 252 | All that said, if you absolutely _must_ remove focus outlines (stubborn client, stubborn boss, stubborn designer, whatever), consider using [`:focus-visible`](https://caniuse.com/css-focus-visible) (and its [polyfill](https://github.com/WICG/focus-visible)) so focus outlines are only hidden from mouse users, _not_ keyboard users. 253 | 254 | ## Inspiration and prior art 255 | 256 | * https://github.com/rafrex/react-router-hash-link 257 | * https://github.com/trevorr/react-scroll-manager 258 | * https://medium.com/@gajus/making-the-anchor-links-work-in-spa-applications-618ba2c6954a 259 | * https://almerosteyn.com/2017/03/accessible-react-navigation 260 | * https://reach.tech/router/accessibility 261 | * https://medium.com/@robdel12/single-page-apps-routers-are-broken-255daa310cf 262 | 263 | ## Related issues 264 | 265 | * https://github.com/alphagov/govuk-frontend/issues/2412 266 | * https://github.com/remix-run/react-router/issues/5210 267 | 268 | ## See also 269 | * [Oaf Routing](https://github.com/oaf-project/oaf-routing) 270 | * [Oaf Side Effects](https://github.com/oaf-project/oaf-side-effects) 271 | * [@axe-core/react](https://github.com/dequelabs/axe-core-npm/tree/develop/packages/react) 272 | * [eslint-plugin-jsx-a11y](https://github.com/evcohen/eslint-plugin-jsx-a11y) 273 | * [React Accessibility](https://reactjs.org/docs/accessibility.html) 274 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "roots": [ 3 | "/src" 4 | ], 5 | "transform": { 6 | "^.+\\.tsx?$": "ts-jest" 7 | }, 8 | "testEnvironment": "jsdom", 9 | "collectCoverage": true, 10 | "coverageThreshold": { 11 | "global": { 12 | "branches": 100, 13 | "functions": 100, 14 | "lines": 100, 15 | "statements": 100 16 | } 17 | }, 18 | // We mess with globals (window.document.title) in the tests so 19 | // this keeps them from interfering with each other. 20 | "maxConcurrency": 1 21 | } 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oaf-react-router", 3 | "version": "4.1.0", 4 | "main": "dist", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/oaf-project/oaf-react-router.git" 9 | }, 10 | "devDependencies": { 11 | "@stryker-mutator/core": "^6.3.1", 12 | "@stryker-mutator/jest-runner": "^6.3.1", 13 | "@stryker-mutator/typescript-checker": "^6.3.1", 14 | "@testing-library/react": "^14.0.0", 15 | "@types/jest": "^29.4.0", 16 | "@types/react-dom": "^18.0.10", 17 | "@typescript-eslint/eslint-plugin": "^5.49.0", 18 | "@typescript-eslint/parser": "^5.49.0", 19 | "codecov": "^3.8.3", 20 | "eslint": "^8.33.0", 21 | "eslint-config-agile-digital": "^2.0.1", 22 | "eslint-config-prettier": "^8.6.0", 23 | "eslint-config-typed-fp": "^4.0.2", 24 | "eslint-plugin-functional": "^5.0.0", 25 | "eslint-plugin-import": "^2.27.5", 26 | "eslint-plugin-jest": "^27.2.1", 27 | "eslint-plugin-jsx-a11y": "^6.7.1", 28 | "eslint-plugin-prettier": "^4.2.1", 29 | "eslint-plugin-react": "^7.32.2", 30 | "eslint-plugin-react-hooks": "^4.6.0", 31 | "eslint-plugin-sonarjs": "^0.18.0", 32 | "eslint-plugin-spellcheck": "^0.0.20", 33 | "eslint-plugin-total-functions": "^6.0.0", 34 | "jest": "^29.4.1", 35 | "jest-environment-jsdom": "^29.4.1", 36 | "prettier": "^2.8.3", 37 | "react": "^18.2.0", 38 | "react-dom": "^18.2.0", 39 | "react-router": "^6.8.0", 40 | "react-router-dom": "^6.8.0", 41 | "total-functions": "^3.0.0", 42 | "ts-jest": "^29.0.5", 43 | "type-coverage": "^2.24.1", 44 | "typescript": "^5.0.0", 45 | "whatwg-fetch": "^3.6.2" 46 | }, 47 | "dependencies": { 48 | "oaf-routing": "^4.2.0", 49 | "rxjs": "^7.8.0" 50 | }, 51 | "peerDependencies": { 52 | "react": "^18.2.0", 53 | "react-router": "^6.6.1", 54 | "react-router-dom": "^6.6.1" 55 | }, 56 | "scripts": { 57 | "build": "tsc", 58 | "lint": "eslint src --ext .ts,.tsx --report-unused-disable-directives", 59 | "format": "prettier --write '{src,test}/**/*.{ts,tsx}'", 60 | "release": "yarn build && yarn lint && yarn type-coverage && yarn publish", 61 | "test": "jest" 62 | }, 63 | "prettier": { 64 | "trailingComma": "all" 65 | }, 66 | "typeCoverage": { 67 | "atLeast": 100, 68 | "ignoreCatch": false, 69 | "strict": true, 70 | "detail": true 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base", 4 | ":preserveSemverRanges" 5 | ], 6 | "transitiveRemediation": true, 7 | "packageRules": [ 8 | { 9 | "matchUpdateTypes": ["minor", "patch"], 10 | "matchCurrentVersion": "!/^0/", 11 | "automerge": true 12 | }, 13 | { 14 | "matchDatasources": ["npm"], 15 | "stabilityDays": 3 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/index.test.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable functional/prefer-immutable-types */ 2 | /* eslint-disable functional/no-return-void */ 3 | /* eslint-disable @typescript-eslint/no-empty-function */ 4 | /* eslint-disable functional/immutable-data */ 5 | /* eslint-disable functional/no-expression-statements */ 6 | /* eslint-disable functional/functional-parameters */ 7 | 8 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 9 | import { wrapRouter } from "."; 10 | import { act, cleanup, render, waitFor } from "@testing-library/react"; 11 | import React from "react"; 12 | 13 | // Polyfill for fetch and global Request required by react-router. 14 | import "whatwg-fetch"; 15 | 16 | beforeEach(() => { 17 | // Avoid `Error: Not implemented: window.scrollTo` 18 | window.scrollTo = () => {}; 19 | // oaf-react-router has a side-effect of manipulating document title (i.e. global mutable state). 20 | window.document.title = ""; 21 | }); 22 | 23 | afterEach(cleanup); 24 | 25 | const setTimeoutPromise = () => 26 | new Promise((resolve) => setTimeout(() => resolve(undefined))); 27 | 28 | describe("oaf-react-router", () => { 29 | test("doesn't throw when wrapping and unwrapping a browser router", () => { 30 | const router = createBrowserRouter([ 31 | { 32 | path: "/", 33 | element:
Hello world!
, 34 | }, 35 | ]); 36 | const unwrap = wrapRouter(router, { documentTitle: () => "test title c" }); 37 | 38 | render( 39 | 40 | 41 | , 42 | ); 43 | 44 | expect(() => unwrap()).not.toThrow(); 45 | }); 46 | 47 | test("disables native scroll restoration", () => { 48 | const router = createBrowserRouter([{}]); 49 | 50 | expect(window.history.scrollRestoration).toBeUndefined(); 51 | 52 | const unwrap = wrapRouter(router, { disableAutoScrollRestoration: true }); 53 | 54 | expect(window.history.scrollRestoration).toEqual("manual"); 55 | 56 | unwrap(); 57 | 58 | expect(window.history.scrollRestoration).toBeUndefined(); 59 | }); 60 | 61 | test("sets the document title after initial render", async () => { 62 | const router = createBrowserRouter([{}]); 63 | wrapRouter(router, { 64 | setPageTitle: true, 65 | documentTitle: () => "test title b", 66 | }); 67 | 68 | expect(document.title).toBe(""); 69 | 70 | await waitFor(() => expect(document.title).toBe("test title b")); 71 | }); 72 | 73 | test("sets the document title after a navigation", async () => { 74 | const router = createBrowserRouter([{}]); 75 | wrapRouter(router, { 76 | setPageTitle: true, 77 | documentTitle: () => "test title a", 78 | }); 79 | 80 | expect(document.title).toBe(""); 81 | 82 | await router.navigate("/"); 83 | 84 | // Prove that `delay(settings.renderTimeout)` is putting the title update on the end of the event loop. 85 | expect(document.title).toBe(""); 86 | 87 | await setTimeoutPromise(); 88 | 89 | // Now, after waiting, we should have updated the page title. 90 | await waitFor(() => expect(document.title).toBe("test title a")); 91 | }); 92 | 93 | test("does not set the document title when setPageTitle is false", async () => { 94 | const router = createBrowserRouter([{}]); 95 | wrapRouter(router, { 96 | setPageTitle: false, 97 | documentTitle: () => "shouldn't happen", 98 | }); 99 | 100 | expect(document.title).toBe(""); 101 | 102 | await router.navigate("/"); 103 | 104 | // We can't just use waitFor with a negative condition that we expect to _remain_ negative after setTimeouts have been allowed to run. 105 | await setTimeoutPromise(); 106 | 107 | await waitFor(() => expect(document.title).toBe("")); 108 | }); 109 | 110 | test("leaves focus alone when repairFocus is false", async () => { 111 | const router = createBrowserRouter([{}]); 112 | 113 | // Given a router wrapper that is set to NOT repair focus. 114 | wrapRouter(router, { repairFocus: false }); 115 | 116 | // and given a default focus target (an h1 element within main). 117 | render( 118 | 119 |
120 |

121 | 122 |
123 |
, 124 | ); 125 | 126 | // And another arbitrary element that happens to currently have focus. 127 | document.querySelector("button")?.focus(); 128 | expect(document.activeElement).toBe(document.querySelector("button")); 129 | 130 | // When we navigate using a wrapped router. 131 | await router.navigate("/"); 132 | 133 | // Then focus remains on the previously focused element. 134 | await waitFor(() => 135 | expect(document.activeElement).toBe(document.querySelector("button")), 136 | ); 137 | }); 138 | 139 | test("moves focus to body when primary focus target cannot be focused", async () => { 140 | const router = createBrowserRouter([ 141 | { 142 | path: "/", 143 | element: ( 144 |
145 |

146 | 147 |
148 | ), 149 | }, 150 | ]); 151 | wrapRouter(router); 152 | 153 | // Given a default focus target (an h1 element within main)... 154 | render( 155 | 156 | 157 | , 158 | ); 159 | 160 | // ...that cannot receive focus (because we sabotaged it) 161 | const h1 = document.querySelector("h1"); 162 | expect(h1).toBeDefined(); 163 | // eslint-disable-next-line functional/no-conditional-statements 164 | if (h1 !== null) { 165 | h1.focus = () => {}; 166 | } 167 | 168 | // And another arbitrary element that happens to currently have focus. 169 | document.querySelector("button")?.focus(); 170 | expect(document.activeElement).toBe(document.querySelector("button")); 171 | 172 | // When we navigate using a wrapped router. 173 | await act(() => router.navigate("/")); 174 | 175 | // Then the wrapper falls back on focusing the body or document 176 | // element when it fails to focus the (sabotaged) H1. 177 | await waitFor(() => 178 | expect([document.body, document.documentElement]).toContain( 179 | document.activeElement, 180 | ), 181 | ); 182 | }); 183 | 184 | test("moves focus to the primary focus target and announce navigation to screen readers", async () => { 185 | // Given a default focus target (an h1 element within main). 186 | const router = createBrowserRouter([ 187 | { 188 | path: "/", 189 | element:
, 190 | loader: () => Promise.resolve(null), 191 | }, 192 | { 193 | path: "/hello", 194 | element: ( 195 |
196 |

197 | 198 |
199 | ), 200 | // The presence of these loaders means that the router will emit loading states before it 201 | // emits idle states (at the completion of the overarching navigation event). 202 | // We only want to update document title, repair focus, announce navigation to screen reader users after 203 | // the final idle state, never in response to the intermediary loading state. 204 | loader: () => Promise.resolve(null), 205 | }, 206 | ]); 207 | 208 | // And a mocked announce function. 209 | const mockAnnounce = jest.fn(function (this: unknown) { 210 | return Promise.resolve(undefined); 211 | }); 212 | wrapRouter(router, { 213 | announce: mockAnnounce, 214 | }); 215 | 216 | render( 217 | 218 | 219 | , 220 | ); 221 | 222 | // And given focus is currently on the body or the document. 223 | expect(document.activeElement).toBe(document.documentElement); 224 | 225 | // When we navigate using a wrapped router. 226 | await act(() => router.navigate({ pathname: "/hello" })); 227 | 228 | // Then the wrapper causes focus to move to that default focus target. 229 | await waitFor(() => expect(document.activeElement).not.toBeNull()); 230 | await waitFor(() => 231 | expect(document.activeElement).toBe(document.querySelector("h1")), 232 | ); 233 | 234 | // And a screen reader announcement was made only for the `idle` state, not the `loading` state. 235 | expect(mockAnnounce.mock.calls).toHaveLength(1); 236 | }); 237 | 238 | test("restores focus after a POP navigation", async () => { 239 | // Given a route. 240 | const router = createBrowserRouter([ 241 | { 242 | path: "/one", 243 | element: ( 244 |
245 |

Page one

246 | 247 |
248 | ), 249 | }, 250 | { 251 | path: "/two", 252 | element: ( 253 |
254 |

Page two

255 |
256 | ), 257 | }, 258 | { 259 | path: "*", 260 | element:
Not found
, 261 | }, 262 | ]); 263 | 264 | // And a wrapped router that restores page state on pop. 265 | wrapRouter(router, { restorePageStateOnPop: true }); 266 | 267 | render( 268 | 269 | 270 | , 271 | ); 272 | 273 | // And given we have previously focused the button. 274 | await act(() => router.navigate({ pathname: "/one" })); 275 | const button = document.querySelector("button"); 276 | // eslint-disable-next-line functional/no-conditional-statements 277 | if (button === null) { 278 | // eslint-disable-next-line functional/no-throw-statements 279 | throw new Error("Expected button not found in DOM"); 280 | } 281 | button.focus(); 282 | expect(document.activeElement).toBe(button); 283 | 284 | // And then navigated away. 285 | await act(() => router.navigate({ pathname: "/two" })); 286 | 287 | // And then waited for React to render. 288 | await waitFor(() => 289 | setTimeoutPromise().then(() => 290 | expect(document.querySelector("h1")).not.toBeNull(), 291 | ), 292 | ); 293 | expect(document.activeElement).toBe(document.querySelector("h1")); 294 | 295 | // When we navigate back (POP). 296 | await act(() => router.navigate(-1)); 297 | 298 | // And wait for React to render. 299 | await waitFor(() => 300 | setTimeoutPromise().then(() => 301 | expect(document.querySelector("button")).not.toBeNull(), 302 | ), 303 | ); 304 | 305 | // Then focus has been moved back to the button. 306 | expect(document.activeElement).toBe(document.querySelector("button")); 307 | }); 308 | 309 | test("stops making changes after unsubscribing", async () => { 310 | // Given a router. 311 | const router = createBrowserRouter([ 312 | { 313 | path: "*", 314 | element:
, 315 | }, 316 | ]); 317 | 318 | // And a mocked announce function. 319 | const mockAnnounce = jest.fn(function (this: unknown) { 320 | return Promise.resolve(undefined); 321 | }); 322 | 323 | // And a wrapped router. 324 | const unsubscribe = wrapRouter(router, { 325 | announce: mockAnnounce, 326 | }); 327 | 328 | render( 329 | 330 | 331 | , 332 | ); 333 | 334 | // When we navigate. 335 | await act(() => router.navigate({ pathname: "/" })); 336 | 337 | // Then the navigation is announced. 338 | await waitFor(() => expect(mockAnnounce.mock.calls).toHaveLength(1)); 339 | 340 | // But when we unsubscribe. 341 | unsubscribe(); 342 | 343 | // And navigate again. 344 | await act(() => router.navigate({ pathname: "/" })); 345 | 346 | // We can't just use waitFor with a negative condition that we expect to _remain_ negative after setTimeouts have been allowed to run. 347 | await setTimeoutPromise(); 348 | 349 | // Then no more announcements are made. 350 | expect(mockAnnounce.mock.calls).toHaveLength(1); 351 | }); 352 | }); 353 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable functional/prefer-immutable-types */ 2 | /* eslint-disable functional/no-expression-statements */ 3 | /* eslint-disable functional/no-return-void */ 4 | /* eslint-disable functional/functional-parameters */ 5 | 6 | import { Router, Location, RouterState } from "@remix-run/router"; 7 | import { 8 | createOafRouter, 9 | defaultSettings as oafRoutingDefaultSettings, 10 | RouterSettings, 11 | } from "oaf-routing"; 12 | import { 13 | concatMap, 14 | delay, 15 | fromEventPattern, 16 | scan, 17 | tap, 18 | defer, 19 | filter, 20 | } from "rxjs"; 21 | 22 | export { RouterSettings } from "oaf-routing"; 23 | 24 | export const defaultSettings: RouterSettings = { 25 | ...oafRoutingDefaultSettings, 26 | }; 27 | 28 | export const wrapRouter = ( 29 | router: Router, 30 | settingsOverrides?: Partial>, 31 | ): (() => void) => { 32 | const settings: RouterSettings = { 33 | ...defaultSettings, 34 | ...settingsOverrides, 35 | }; 36 | 37 | const oafRouter = createOafRouter(settings, (location) => location.hash); 38 | 39 | const initialState = router.state; 40 | 41 | // TODO: fold this into the RxJS observable below. 42 | // HACK: We use setTimeout to give React a chance to render before we repair focus. 43 | setTimeout(() => { 44 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 45 | oafRouter.handleFirstPageLoad(initialState.location); 46 | }, settings.renderTimeout); 47 | 48 | /** 49 | * The Typescript type of `fromEventPattern` is... not good. 50 | * 51 | * The implementation below _could_ simplify down to the following: 52 | * 53 | * ``` 54 | * fromEventPattern( 55 | * (handler) => router.subscribe(handler), 56 | * (_, unsubscribe: () => void) => unsubscribe(), 57 | * ) 58 | * ``` 59 | * 60 | * but then we would be vulnerable to changes that affect either the `RouterState` type or the `() => void` type. 61 | * Those could change and we wouldn't know about it, because `fromEventPattern` doesn't attempt to derive those 62 | * types from its parameters, instead just resorting to `any`. By specifying the types we are in effect performing an (unsafe) 63 | * type assertion. 64 | * 65 | * To make the type assertion safer, we use the `Parameters` and `ReturnType` helpers to derive the types 66 | * from the type of the router parameter directly. The result is the same, but instead of being entirely decoupled from the actual 67 | * types defined by the `Router` type, we will pick up any changes. 68 | * 69 | * The tradeoff is that it's uglier and more verbose, but in exchange we get much more type safety. 70 | * 71 | * The real solution would be to fix the `any` typings in `fromEventPattern` upstream. 72 | */ 73 | const routerObservable = (router: Router) => 74 | fromEventPattern< 75 | Parameters[0]>[0] 76 | >( 77 | (handler) => router.subscribe(handler), 78 | (_, unsubscribe: ReturnType<(typeof router)["subscribe"]>) => 79 | unsubscribe(), 80 | ); 81 | 82 | /** 83 | * To make decisions about how to handle route changes, we want to know the 84 | * previous router state as well as the current/next router state. 85 | * 86 | * This type allows our router subscription to hang onto that little bit of state 87 | * (the previous router state) via RxJS's `scan`. 88 | */ 89 | // eslint-disable-next-line functional/type-declaration-immutability 90 | type StateAccumulator = { 91 | readonly previousState: RouterState; 92 | readonly state: RouterState; 93 | }; 94 | 95 | // TODO: push this RxJS pipeline down into oaf-router and have consumer libs like oaf-react-router 96 | // be responsible only for creating and passing in the routerObservable? 97 | const subscription = routerObservable(router) 98 | .pipe( 99 | // Filter submitted and loading navigation events. We don't want to repair focus, announce navigation or restore scroll/focus 100 | // until navigation has settled back to the idle state again. See https://reactrouter.com/en/main/hooks/use-navigation#navigationstate 101 | filter((state) => state.navigation.state === "idle"), 102 | scan>( 103 | (acc, nextState) => ({ 104 | previousState: acc.state, 105 | state: nextState, 106 | }), 107 | { state: initialState }, 108 | ), 109 | tap(({ previousState, state }) => 110 | // We're the first subscribed listener, so the DOM won't have been updated yet. 111 | oafRouter.handleLocationWillChange( 112 | previousState.location.key, 113 | state.location.key, 114 | state.historyAction, 115 | ), 116 | ), 117 | // HACK: Give React a chance to render before we repair focus. 118 | // TODO: This will likely fall apart with suspense / async rendering. 119 | // At that point, we may have to tap into React (and React Router) more directly. Thus far, the trade-off 120 | // made by oaf-router has been to do everything via the DOM directly and remain framework agnostic. 121 | // That has maximized its reusability (check out https://github.com/oaf-project/oaf-routing#libraries-that-use-oaf-routing) 122 | // at the cost of not being able to intimately tie into specific framework / router life cycles. 123 | // When frameworks and routers are simple, we can get away with the delay/setTimeout hack. But if they're not... 124 | delay(settings.renderTimeout), 125 | concatMap(({ previousState, state }) => 126 | defer(() => 127 | oafRouter.handleLocationChanged( 128 | previousState.location, 129 | state.location, 130 | state.location.key, 131 | state.historyAction, 132 | ), 133 | ), 134 | ), 135 | ) 136 | .subscribe(); 137 | 138 | return (): void => { 139 | oafRouter.resetAutoScrollRestoration(); 140 | subscription.unsubscribe(); 141 | }; 142 | }; 143 | -------------------------------------------------------------------------------- /stryker.conf.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | packageManager: "yarn", 3 | reporters: ["clear-text", "progress", "dashboard"], 4 | testRunner: "jest", 5 | coverageAnalysis: "perTest", 6 | checkers: ["typescript"], 7 | tsconfigFile: "tsconfig.json", 8 | mutate: ["src/**/*.ts", "!src/**/*.test.tsx"], 9 | thresholds: { high: 80, low: 60, break: 40 }, 10 | timeoutMS: 10000, 11 | }; 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "jsx": "react", 4 | "target": "es5", 5 | "lib": [ 6 | "esnext", 7 | "dom" 8 | ], 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "outDir": "dist", 12 | "declaration": true, 13 | "allowJs": false, 14 | "skipLibCheck": true, 15 | "esModuleInterop": true, 16 | "allowSyntheticDefaultImports": true, 17 | "strict": true, 18 | "noUncheckedIndexedAccess": true, 19 | "noImplicitReturns": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "forceConsistentCasingInFileNames": true 23 | }, 24 | "include": [ 25 | "src" 26 | ] 27 | } 28 | --------------------------------------------------------------------------------