├── .eslintignore ├── .eslintrc ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── index.js ├── package-lock.json ├── package.json └── test ├── .eslintrc ├── manual ├── index.html └── manual.js └── test.js /.eslintignore: -------------------------------------------------------------------------------- 1 | .gitignore -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint:recommended", 3 | "env": { 4 | "es6": false, 5 | "node": true, 6 | "browser": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.log 3 | .DS_Store 4 | coverage 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 10 4 | cache: 5 | directories: 6 | - node_modules 7 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.1.0 4 | 5 | - Add `preventDefault` option. 6 | 7 | ## 1.0.0 8 | 9 | - Release 1.0.0, because it's time. 10 | 11 | ## 0.4.0 12 | 13 | - **[Breaking change]**: Add `skipFragment` option, defaulting to `true`. 14 | So links to fragments (e.g. `href="#foo"`) are no longer hijacked by default. 15 | 16 | ## 0.3.2 17 | 18 | - Start this log. 19 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | As contributors and maintainers of this project, and in the interest of fostering an open and welcoming community, we pledge to respect all people who contribute through reporting issues, posting feature requests, updating documentation, submitting pull requests or patches, and other activities. 4 | 5 | We are committed to making participation in this project a harassment-free experience for everyone, regardless of level of experience, gender, gender identity and expression, sexual orientation, disability, personal appearance, body size, race, ethnicity, age, religion, or nationality. 6 | 7 | Examples of unacceptable behavior by participants include: 8 | 9 | * The use of sexualized language or imagery 10 | * Personal attacks 11 | * Trolling or insulting/derogatory comments 12 | * Public or private harassment 13 | * Publishing other's private information, such as physical or electronic addresses, without explicit permission 14 | * Other unethical or unprofessional conduct 15 | 16 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct. By adopting this Code of Conduct, project maintainers commit themselves to fairly and consistently applying these principles to every aspect of managing this project. Project maintainers who do not follow or enforce the Code of Conduct may be permanently removed from the project team. 17 | 18 | This code of conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. 19 | 20 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting one or more of the project maintainers. 21 | 22 | This Code of Conduct is adapted from the [Contributor Covenant](http://contributor-covenant.org), version 1.2.0, available at [http://contributor-covenant.org/version/1/2/0/](http://contributor-covenant.org/version/1/2/0/) 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Mapbox 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 | # @mapbox/link-hijacker 2 | 3 | [![Build Status](https://travis-ci.com/mapbox/link-hijacker.svg?branch=main)](https://travis-ci.com/mapbox/link-hijacker) 4 | 5 | Hijack clicks on and within links, probably for client-side routing. 6 | 7 | ![Pirates hijacking a ship](https://upload.wikimedia.org/wikipedia/commons/thumb/8/84/Painting_of_a_pirate_ship_%28after_1852%29%2C_after_Ambroise_Louis_Garneray.jpg/640px-Painting_of_a_pirate_ship_%28after_1852%29%2C_after_Ambroise_Louis_Garneray.jpg) 8 | 9 | Imagine you're using client-side routing on your website, because you live in The Future. 10 | You want nice, smooth, fast client-side routing whenever a link points to a client-side route (regardless of whether the code author remembered this). And you want a regular old page transition whenever a link does *not* point to a client-side route. 11 | The easiest way to do this would be to use regular `` elements all over your site, for *both* of these types of links. 12 | That would be convenient, and also wouldn't force you or others to pay attention to which links should do client-side routing and which ones should not. 13 | That seems like a better situation than, for example, using a [special component](https://reacttraining.com/react-router/web/api/Link) to distinguish between regular and client-side links. 14 | 15 | link-hijacker provides the means to do this by hijacking all clicks on and within links, allowing you to determine how they are treated. 16 | 17 | - Listens for clicks. 18 | - Determines which clicks are on or within `` elements with `href` attributes. 19 | - Determines whether those links should be hijacked, based on your options. 20 | - If a link should be hijacked, prevents default behavior and calls your callback. 21 | - In your callback, you might programmatically change pages. 22 | 23 | This pattern has been implemented before, because it's clearly useful (see ["Similar work"]). 24 | But there didn't seem to be a full-featured, well-tested, and actively maintained implementation that was not tied to a larger library. 25 | So we made this one. 26 | 27 | ## Typical example 28 | 29 | ```js 30 | const linkHijacker = require('@mapbox/link-hijacker'); 31 | 32 | const unhijack = linkHijacker.hijack((clickedLink, clickEvent) => { 33 | // Determine whether the link points to a client-side route ... 34 | if (linkPointsToClientSideRoute) { 35 | // Use JS for to programmatically change the page ... 36 | } else { 37 | // Or else allow it to work like a regular link. 38 | window.location.assign(clickedLink); 39 | } 40 | }); 41 | 42 | // Later, you can unhijack links. 43 | unhijack(); 44 | ``` 45 | 46 | You can now use `` elements indiscriminately. 47 | 48 | ## API 49 | 50 | ### hijack 51 | 52 | `linkHijacker.hijack([options], callback)` 53 | 54 | Returns a function that can be used to remove event listeners, unhijacking links. 55 | Calls the `callback` whenever a link is hijacked. 56 | 57 | #### options 58 | 59 | ##### root 60 | 61 | Type: `HtmlElement`. Default: `document.documentElement`. 62 | 63 | Links will be hijacked within this element. 64 | 65 | ##### skipModifierKeys 66 | 67 | Type: `boolean`. Default: `true`. 68 | 69 | By default, clicks paired with modifiers keys (`ctrlKey`, `altKey`, `metaKey`, `shiftKey`) are *not* hijacked. 70 | If this option is `false`, these clicks *will* be hijacked. 71 | 72 | ##### skipDownload 73 | 74 | Type: `boolean`. Default: `true`. 75 | 76 | By default, links with the `download` attribute are *not* hijacked. 77 | If this option is `false`, these links *will* be hijacked. 78 | 79 | ##### skipTargetBlank 80 | 81 | Type: `boolean`. Default: `true`. 82 | 83 | By default, links with the attribute `target="_blank"` are *not* hijacked. 84 | If this option is `false`, these links *will* be hijacked. 85 | 86 | ##### skipExternal 87 | 88 | Type: `boolean`. Default: `true`. 89 | 90 | By default, links with the attribute `rel="external"` are *not* hijacked. 91 | If this option is `false`, these links *will* be hijacked. 92 | 93 | ##### skipMailTo 94 | 95 | Type: `boolean`. Default: `true`. 96 | 97 | By default, links whose `href` attributes start with `mailto:` are *not* hijacked. 98 | If this option is `false`, these links *will* be hijacked. 99 | 100 | ##### skipOtherOrigin 101 | 102 | Type: `boolean`. Default: `true`. 103 | 104 | By default, links pointing to other origins (protocol + domain) are *not* hijacked. 105 | If this option is `false`, these links *will* be hijacked. 106 | 107 | ##### skipFragment 108 | 109 | Type: `boolean`. Default: `true`. 110 | 111 | By default, links with `href` attributes starting with fragments (e.g. `href="#foo"`) are *not* hijacked. 112 | (Links with `href` attributes that *include* fragments, but don't start with them, will still be hijacked, e.g. `href="some/page#foo"`.) 113 | If this option is `false`, these links *will* be hijacked. 114 | 115 | ##### skipFilter 116 | 117 | Type: `Function`. 118 | 119 | A filter function that receives the clicked link element and returns a truthy or falsey value indicating whether the link should be hijacked or not. 120 | If it returns a falsey value, the link will be hijacked. 121 | If the function returns a truthy value, the link will not be hijacked. 122 | 123 | ##### preventDefault 124 | 125 | Type: `boolean`. Default: `true`. 126 | 127 | By default, `event.preventDefault()` will be called on any click events that are hijacked (are *not* skipped). 128 | If this option is `false`, `event.preventDefault()` will *not* be called. 129 | You could let the event continue as normal, or prevent default behavior yourself. 130 | 131 | #### callback 132 | 133 | Type: `Function`. 134 | **Required.** 135 | 136 | Whenever a link is clicked, the `callback` will be invoked with two arguments: 137 | 138 | - `link`: The link element that was clicked on or within. 139 | - `event`: The `click` event. 140 | 141 | ## Similar work 142 | 143 | - [page.js](https://github.com/visionmedia/page.js/blob/1034c8cbed600ea7da378a73716c885227c03270/index.js#L541-L601) 144 | - [nanohref]( https://github.com/yoshuawuyts/nanohref/blob/4efcc2c0becd2822a31c912364997cf03c66ab8d/index.js) 145 | - [whir-tools/hijack-links](https://github.com/whir-tools/hijack-links) 146 | 147 | ["Similar work"]: #similar-work 148 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | function getClosestLink(node, root) { 4 | if (!node || node === root) return; 5 | if ('a' !== node.nodeName.toLowerCase() || !node.href) { 6 | return getClosestLink(node.parentNode, root); 7 | } 8 | return node; 9 | } 10 | 11 | function setDefault(x, d) { 12 | return x === undefined ? d : x; 13 | } 14 | 15 | function hijack(options, callback) { 16 | if (typeof window === 'undefined') return; 17 | if (typeof options === 'function') { 18 | callback = options; 19 | } 20 | if (callback === undefined) { 21 | throw new Error('hijack requires a callback'); 22 | } 23 | var root = setDefault(options.root, document.documentElement); 24 | var skipModifierKeys = setDefault(options.skipModifierKeys, true); 25 | var skipDownload = setDefault(options.skipDownload, true); 26 | var skipTargetBlank = setDefault(options.skipTargetBlank, true); 27 | var skipExternal = setDefault(options.skipExternal, true); 28 | var skipMailTo = setDefault(options.skipMailTo, true); 29 | var skipOtherOrigin = setDefault(options.skipOtherOrigin, true); 30 | var skipFragment = setDefault(options.skipFragment, true); 31 | var preventDefault = setDefault(options.preventDefault, true); 32 | 33 | function onClick(e) { 34 | if (e.defaultPrevented) return; 35 | if (e.button && e.button !== 0) return; 36 | 37 | if ( 38 | skipModifierKeys && 39 | (e.ctrlKey || e.metaKey || e.altKey || e.shiftKey) 40 | ) { 41 | return; 42 | } 43 | 44 | var link = getClosestLink(e.target, root); 45 | if (!link) return; 46 | 47 | if (options.skipFilter && options.skipFilter(link)) return; 48 | if (skipFragment && /^#/.test(link.getAttribute('href'))) return; 49 | if (skipDownload && link.hasAttribute('download')) return; 50 | if (skipExternal && link.getAttribute('rel') === 'external') return; 51 | if (skipTargetBlank && link.getAttribute('target') === '_blank') return; 52 | if (skipMailTo && /mailto:/.test(link.getAttribute('href'))) return; 53 | // IE doesn't populate all link properties when setting href with a 54 | // relative URL. However, href will return an absolute URL which then can 55 | // be used on itself to populate these additional fields. 56 | // https://stackoverflow.com/a/13405933/2284669 57 | if (!link.host) link.href = link.href; 58 | if ( 59 | skipOtherOrigin && 60 | /:\/\//.test(link.href) && 61 | (location.protocol !== link.protocol || location.host !== link.host) 62 | ) { 63 | return; 64 | } 65 | 66 | if (preventDefault) { 67 | e.preventDefault(); 68 | } 69 | callback(link, e); 70 | } 71 | 72 | root.addEventListener('click', onClick); 73 | return function unhijackLinks() { 74 | root.removeEventListener('click', onClick); 75 | }; 76 | } 77 | 78 | module.exports = { 79 | hijack: hijack 80 | }; 81 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mapbox/link-hijacker", 3 | "version": "1.1.0", 4 | "description": "Hijack clicks on and within links, probably for client-side routing", 5 | "main": "index.js", 6 | "scripts": { 7 | "format": "prettier --single-quote --write '{,test/**/}*.js'", 8 | "precommit": "lint-staged", 9 | "lint": "eslint .", 10 | "test-manual": "budo test/manual/manual.js -l -d test/manual", 11 | "start": "npm run test-manual", 12 | "test-jest": "jest", 13 | "pretest": "npm run lint", 14 | "test": "jest" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/mapbox/link-hijacker.git" 19 | }, 20 | "keywords": [ 21 | "client-side-routing", 22 | "routing", 23 | "hijack", 24 | "links" 25 | ], 26 | "author": "Mapbox", 27 | "license": "MIT", 28 | "bugs": { 29 | "url": "https://github.com/mapbox/link-hijacker/issues" 30 | }, 31 | "homepage": "https://github.com/mapbox/link-hijacker#readme", 32 | "devDependencies": { 33 | "budo": "^10.0.3", 34 | "eslint": "^4.1.0", 35 | "husky": "^0.14.1", 36 | "jest": "^20.0.4", 37 | "lint-staged": "^4.0.0", 38 | "prettier": "^1.4.4" 39 | }, 40 | "lint-staged": { 41 | "**/*.js": [ 42 | "eslint", 43 | "prettier --single-quote --write", 44 | "git add" 45 | ] 46 | }, 47 | "jest": { 48 | "coverageReporters": [ 49 | "text", 50 | "html" 51 | ], 52 | "clearMocks": true, 53 | "roots": [ 54 | "./test" 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "jest": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /test/manual/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | link-hijacker test 6 | 7 | 8 | 9 | 10 | 11 |

link-hijacker test cases

12 | 13 |
14 | 15 |
16 | 17 |
18 |
19 | just a link 20 |
21 |
22 | 25 |
0
26 |
27 |
28 | 29 |
30 |
31 | link with nested spans 32 |
33 |
34 | 45 |
0
46 |
47 |
48 | 49 |
50 |
51 | button nested inside a link 52 |
53 |
54 | 65 |
0
66 |
67 |
68 | 69 |
70 |
71 | image nested inside a link 72 |
73 |
74 | 83 |
0
84 |
85 |
86 | 87 |
88 |
89 | download link 90 |
91 |
92 |
93 | download 94 |
95 |
0
96 |
97 |
98 | 99 |
100 |
101 | rel="external" link 102 |
103 |
104 |
105 | external 106 |
107 |
0
108 |
109 |
110 | 111 |
112 |
113 | target="_blank" link 114 |
115 |
116 |
117 | target-blank 118 |
119 |
0
120 |
121 |
122 | 123 |
124 |
125 | mailto: link 126 |
127 |
128 |
129 | mailto 130 |
131 |
0
132 |
133 |
134 | 135 |
136 |
137 | link to other origin 138 |
139 |
140 |
141 | other-origin 142 |
143 |
0
144 |
145 |
146 | 147 |
148 |
149 | default prevented 150 |
151 |
152 |
153 | default-prevented 154 |
155 |
0
156 |
157 |
158 | 159 |
160 |
161 | matches skipFilter 162 |
163 |
164 |
165 | skip-filter-true 166 |
167 |
0
168 |
169 |
170 | 171 |
172 |
173 | does not match skipFilter 174 |
175 |
176 |
177 | skip-filter-false 178 |
179 |
0
180 |
181 |
182 | 183 |
184 |
185 | anchor without href 186 |
187 |
188 |
189 | anchor-without-href 190 |
191 |
0
192 |
193 |
194 | 195 |
196 |
197 | anchor with fragment href 198 |
199 |
200 |
201 | anchor-with-fragment 202 |
203 |
0
204 |
205 |
206 | 207 |
208 |
209 | anchor with href URL containing (but not starting with) fragment 210 |
211 |
212 | 215 |
0
216 |
217 |
218 | 219 |
220 |
221 | Default behavior is not prevented. 222 |
223 |
224 |
225 | default-behavior 226 |
227 |
0
228 |
229 |
230 | 231 | 232 | 233 | 234 | -------------------------------------------------------------------------------- /test/manual/manual.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var linkHijacker = require('../..'); 4 | 5 | document 6 | .getElementById('default-prevented') 7 | .addEventListener('click', function(e) { 8 | e.preventDefault(); 9 | }); 10 | 11 | function logHijacking(link) { 12 | var countContainer = document.getElementById(link.id + '-count'); 13 | var currentCount = Number(countContainer.innerText); 14 | var nextCount = currentCount + 1; 15 | countContainer.innerText = nextCount; 16 | } 17 | 18 | var stop = linkHijacker.hijack(logHijacking); 19 | 20 | var usingDefaults = true; 21 | var switchButton = document.getElementById('switch-options'); 22 | switchButton.addEventListener('click', function() { 23 | if (usingDefaults === true) { 24 | stop(); 25 | stop = linkHijacker.hijack( 26 | { 27 | skipModifierKeys: false, 28 | skipDownload: false, 29 | skipTargetBlank: false, 30 | skipExternal: false, 31 | skipMailTo: false, 32 | skipOtherOrigin: false, 33 | skipFragment: false, 34 | skipFilter: function(link) { 35 | return link.hasAttribute('data-no-hijack'); 36 | } 37 | }, 38 | logHijacking 39 | ); 40 | usingDefaults = false; 41 | switchButton.innerText = 'use defaults'; 42 | } else { 43 | stop(); 44 | stop = linkHijacker.hijack(logHijacking); 45 | usingDefaults = true; 46 | switchButton.innerText = 'set options'; 47 | } 48 | }); 49 | -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | const linkHijacker = require('..'); 4 | 5 | describe('hijack', () => { 6 | let root; 7 | let link; 8 | let mockEvent; 9 | let remove; 10 | 11 | beforeEach(() => { 12 | root = { 13 | addEventListener: jest.fn(), 14 | removeEventListener: jest.fn() 15 | }; 16 | link = global.document.createElement('a'); 17 | link.href = 'about:/foo/bar'; 18 | global.document.body.appendChild(link); 19 | mockEvent = { 20 | preventDefault: jest.fn(), 21 | target: link 22 | }; 23 | }); 24 | 25 | afterEach(() => { 26 | global.document.body.removeChild(link); 27 | remove(); 28 | }); 29 | 30 | test('adds a listener', () => { 31 | const options = { root }; 32 | const callback = () => {}; 33 | remove = linkHijacker.hijack(options, callback); 34 | expect(root.addEventListener).toHaveBeenCalledTimes(1); 35 | expect(root.addEventListener.mock.calls[0][0]).toBe('click'); 36 | }); 37 | 38 | test('can remove the listener', () => { 39 | const options = { root }; 40 | const callback = () => {}; 41 | remove = linkHijacker.hijack(options, callback); 42 | remove(); 43 | const handler = root.addEventListener.mock.calls[0][1]; 44 | expect(root.removeEventListener).toHaveBeenCalledTimes(1); 45 | expect(root.removeEventListener.mock.calls[0][0]).toBe('click'); 46 | expect(root.removeEventListener.mock.calls[0][1]).toBe(handler); 47 | }); 48 | 49 | test('hijacks links, preventing default', () => { 50 | let callbackCalled = false; 51 | remove = linkHijacker.hijack({ root }, (clickedLink, clickEvent) => { 52 | callbackCalled = true; 53 | expect(clickedLink).toBe(link); 54 | expect(clickEvent).toBe(mockEvent); 55 | }); 56 | const handler = root.addEventListener.mock.calls[0][1]; 57 | handler(mockEvent); 58 | expect(callbackCalled).toBe(true); 59 | expect(mockEvent.preventDefault).toHaveBeenCalledTimes(1); 60 | }); 61 | 62 | test('hijacks links when click is on nested element', () => { 63 | const nestedEl = global.document.createElement('div'); 64 | link.appendChild(nestedEl); 65 | mockEvent.target = nestedEl; 66 | let callbackCalled = false; 67 | remove = linkHijacker.hijack({ root }, (clickedLink, clickEvent) => { 68 | callbackCalled = true; 69 | expect(clickedLink).toBe(link); 70 | expect(clickEvent).toBe(mockEvent); 71 | }); 72 | const handler = root.addEventListener.mock.calls[0][1]; 73 | handler(mockEvent); 74 | expect(callbackCalled).toBe(true); 75 | }); 76 | 77 | test('skips defaultPrevented', () => { 78 | let callbackCalled = false; 79 | remove = linkHijacker.hijack({ root }, () => { 80 | callbackCalled = true; 81 | }); 82 | const handler = root.addEventListener.mock.calls[0][1]; 83 | mockEvent.defaultPrevented = true; 84 | handler(mockEvent); 85 | expect(callbackCalled).toBe(false); 86 | }); 87 | 88 | test('skips right click', () => { 89 | let callbackCalled = false; 90 | remove = linkHijacker.hijack({ root }, () => { 91 | callbackCalled = true; 92 | }); 93 | const handler = root.addEventListener.mock.calls[0][1]; 94 | mockEvent.button = 1; 95 | handler(mockEvent); 96 | expect(callbackCalled).toBe(false); 97 | }); 98 | 99 | test('skips ctrl key', () => { 100 | let callbackCalled = false; 101 | remove = linkHijacker.hijack({ root }, () => { 102 | callbackCalled = true; 103 | }); 104 | const handler = root.addEventListener.mock.calls[0][1]; 105 | mockEvent.ctrlKey = true; 106 | handler(mockEvent); 107 | expect(callbackCalled).toBe(false); 108 | }); 109 | 110 | test('options.skipModifierKeys = false does not skip ctrl key', () => { 111 | let callbackCalled = false; 112 | remove = linkHijacker.hijack( 113 | { 114 | root, 115 | skipModifierKeys: false 116 | }, 117 | () => { 118 | callbackCalled = true; 119 | } 120 | ); 121 | const handler = root.addEventListener.mock.calls[0][1]; 122 | mockEvent.ctrlKey = true; 123 | handler(mockEvent); 124 | expect(callbackCalled).toBe(true); 125 | }); 126 | 127 | test('skips meta key', () => { 128 | let callbackCalled = false; 129 | remove = linkHijacker.hijack({ root }, () => { 130 | callbackCalled = true; 131 | }); 132 | const handler = root.addEventListener.mock.calls[0][1]; 133 | mockEvent.metaKey = true; 134 | handler(mockEvent); 135 | expect(callbackCalled).toBe(false); 136 | }); 137 | 138 | test('options.skipModifierKeys = false does not skip meta key', () => { 139 | let callbackCalled = false; 140 | remove = linkHijacker.hijack( 141 | { 142 | root, 143 | skipModifierKeys: false 144 | }, 145 | () => { 146 | callbackCalled = true; 147 | } 148 | ); 149 | const handler = root.addEventListener.mock.calls[0][1]; 150 | mockEvent.metaKey = true; 151 | handler(mockEvent); 152 | expect(callbackCalled).toBe(true); 153 | }); 154 | 155 | test('skips alt key', () => { 156 | let callbackCalled = false; 157 | remove = linkHijacker.hijack({ root }, () => { 158 | callbackCalled = true; 159 | }); 160 | const handler = root.addEventListener.mock.calls[0][1]; 161 | mockEvent.altKey = true; 162 | handler(mockEvent); 163 | expect(callbackCalled).toBe(false); 164 | }); 165 | 166 | test('options.skipModifierKeys = false does not skip alt key', () => { 167 | let callbackCalled = false; 168 | remove = linkHijacker.hijack( 169 | { 170 | root, 171 | skipModifierKeys: false 172 | }, 173 | () => { 174 | callbackCalled = true; 175 | } 176 | ); 177 | const handler = root.addEventListener.mock.calls[0][1]; 178 | mockEvent.altKey = true; 179 | handler(mockEvent); 180 | expect(callbackCalled).toBe(true); 181 | }); 182 | 183 | test('skips shift key', () => { 184 | let callbackCalled = false; 185 | remove = linkHijacker.hijack({ root }, () => { 186 | callbackCalled = true; 187 | }); 188 | const handler = root.addEventListener.mock.calls[0][1]; 189 | mockEvent.shiftKey = true; 190 | handler(mockEvent); 191 | expect(callbackCalled).toBe(false); 192 | }); 193 | 194 | test('options.skipModifierKeys = false does not skip shift key', () => { 195 | let callbackCalled = false; 196 | remove = linkHijacker.hijack( 197 | { 198 | root, 199 | skipModifierKeys: false 200 | }, 201 | () => { 202 | callbackCalled = true; 203 | } 204 | ); 205 | const handler = root.addEventListener.mock.calls[0][1]; 206 | mockEvent.shiftKey = true; 207 | handler(mockEvent); 208 | expect(callbackCalled).toBe(true); 209 | }); 210 | 211 | test('skips elements with no link parent', () => { 212 | let callbackCalled = false; 213 | remove = linkHijacker.hijack({ root }, () => { 214 | callbackCalled = true; 215 | }); 216 | const handler = root.addEventListener.mock.calls[0][1]; 217 | const el = global.document.createElement('div'); 218 | global.document.body.appendChild(el); 219 | mockEvent.target = el; 220 | handler(mockEvent); 221 | expect(callbackCalled).toBe(false); 222 | }); 223 | 224 | test('skips download', () => { 225 | let callbackCalled = false; 226 | remove = linkHijacker.hijack({ root }, () => { 227 | callbackCalled = true; 228 | }); 229 | const handler = root.addEventListener.mock.calls[0][1]; 230 | link.setAttribute('download', true); 231 | handler(mockEvent); 232 | expect(callbackCalled).toBe(false); 233 | }); 234 | 235 | test('options.skipDownload = false does not skip download', () => { 236 | let callbackCalled = false; 237 | remove = linkHijacker.hijack( 238 | { 239 | root, 240 | skipDownload: false 241 | }, 242 | () => { 243 | callbackCalled = true; 244 | } 245 | ); 246 | const handler = root.addEventListener.mock.calls[0][1]; 247 | link.setAttribute('download', true); 248 | handler(mockEvent); 249 | expect(callbackCalled).toBe(true); 250 | }); 251 | 252 | test('skips rel="external"', () => { 253 | let callbackCalled = false; 254 | remove = linkHijacker.hijack({ root }, () => { 255 | callbackCalled = true; 256 | }); 257 | const handler = root.addEventListener.mock.calls[0][1]; 258 | link.setAttribute('rel', 'external'); 259 | handler(mockEvent); 260 | expect(callbackCalled).toBe(false); 261 | }); 262 | 263 | test('options.skipExternal = false does not skip rel="external"', () => { 264 | let callbackCalled = false; 265 | remove = linkHijacker.hijack( 266 | { 267 | root, 268 | skipExternal: false 269 | }, 270 | () => { 271 | callbackCalled = true; 272 | } 273 | ); 274 | const handler = root.addEventListener.mock.calls[0][1]; 275 | link.setAttribute('rel', 'external'); 276 | handler(mockEvent); 277 | expect(callbackCalled).toBe(true); 278 | }); 279 | 280 | test('skips target="_blank"', () => { 281 | let callbackCalled = false; 282 | remove = linkHijacker.hijack({ root }, () => { 283 | callbackCalled = true; 284 | }); 285 | const handler = root.addEventListener.mock.calls[0][1]; 286 | link.setAttribute('target', '_blank'); 287 | handler(mockEvent); 288 | expect(callbackCalled).toBe(false); 289 | }); 290 | 291 | test('options.skipTargetBlank = false does not skip target="_blank"', () => { 292 | let callbackCalled = false; 293 | remove = linkHijacker.hijack( 294 | { 295 | root, 296 | skipTargetBlank: false 297 | }, 298 | () => { 299 | callbackCalled = true; 300 | } 301 | ); 302 | const handler = root.addEventListener.mock.calls[0][1]; 303 | link.setAttribute('target', '_blank'); 304 | handler(mockEvent); 305 | expect(callbackCalled).toBe(true); 306 | }); 307 | 308 | test('skips mailto', () => { 309 | let callbackCalled = false; 310 | remove = linkHijacker.hijack({ root }, () => { 311 | callbackCalled = true; 312 | }); 313 | const handler = root.addEventListener.mock.calls[0][1]; 314 | link.setAttribute('href', 'mailto:fake@gmail.com'); 315 | handler(mockEvent); 316 | expect(callbackCalled).toBe(false); 317 | }); 318 | 319 | test('options.skipMailTo = false does not skip mailto', () => { 320 | let callbackCalled = false; 321 | remove = linkHijacker.hijack( 322 | { 323 | root, 324 | skipMailTo: false 325 | }, 326 | () => { 327 | callbackCalled = true; 328 | } 329 | ); 330 | const handler = root.addEventListener.mock.calls[0][1]; 331 | link.setAttribute('href', 'mailto:fake@gmail.com'); 332 | handler(mockEvent); 333 | expect(callbackCalled).toBe(true); 334 | }); 335 | 336 | test('skips links to another host', () => { 337 | let callbackCalled = false; 338 | remove = linkHijacker.hijack({ root }, () => { 339 | callbackCalled = true; 340 | }); 341 | const handler = root.addEventListener.mock.calls[0][1]; 342 | link.setAttribute('href', 'https://google.com'); 343 | handler(mockEvent); 344 | expect(callbackCalled).toBe(false); 345 | }); 346 | 347 | test('options.skipFilter', () => { 348 | let callbackCalled = false; 349 | remove = linkHijacker.hijack( 350 | { 351 | root, 352 | skipFilter: link => link.hasAttribute('data-no-hijack') 353 | }, 354 | () => { 355 | callbackCalled = true; 356 | } 357 | ); 358 | const handler = root.addEventListener.mock.calls[0][1]; 359 | link.setAttribute('href', 'about:/path/to/place'); 360 | link.setAttribute('data-no-hijack', ''); 361 | handler(mockEvent); 362 | expect(callbackCalled).toBe(false); 363 | link.removeAttribute('data-no-hijack'); 364 | handler(mockEvent); 365 | expect(callbackCalled).toBe(true); 366 | }); 367 | 368 | test('skips anchor without href', () => { 369 | let callbackCalled = false; 370 | remove = linkHijacker.hijack({ root }, () => { 371 | callbackCalled = true; 372 | }); 373 | const handler = root.addEventListener.mock.calls[0][1]; 374 | link.removeAttribute('href'); 375 | handler(mockEvent); 376 | expect(callbackCalled).toBe(false); 377 | }); 378 | 379 | test('skips fragments', () => { 380 | let callbackCalled = false; 381 | remove = linkHijacker.hijack({ root }, () => { 382 | callbackCalled = true; 383 | }); 384 | const handler = root.addEventListener.mock.calls[0][1]; 385 | link.setAttribute('href', '#foo'); 386 | handler(mockEvent); 387 | expect(callbackCalled).toBe(false); 388 | }); 389 | 390 | test('does not skip URLs ending with fragments', () => { 391 | let callbackCalled = false; 392 | remove = linkHijacker.hijack({ root }, () => { 393 | callbackCalled = true; 394 | }); 395 | const handler = root.addEventListener.mock.calls[0][1]; 396 | link.setAttribute('href', '/foo/bar#baz'); 397 | handler(mockEvent); 398 | expect(callbackCalled).toBe(true); 399 | }); 400 | 401 | test('does not skip URLs ending with slash + fragments', () => { 402 | let callbackCalled = false; 403 | remove = linkHijacker.hijack({ root }, () => { 404 | callbackCalled = true; 405 | }); 406 | const handler = root.addEventListener.mock.calls[0][1]; 407 | link.setAttribute('href', '/foo/bar/#baz'); 408 | handler(mockEvent); 409 | expect(callbackCalled).toBe(true); 410 | }); 411 | 412 | test('options.skipFragment', () => { 413 | let callbackCalled = false; 414 | remove = linkHijacker.hijack({ root, skipFragment: false }, () => { 415 | callbackCalled = true; 416 | }); 417 | const handler = root.addEventListener.mock.calls[0][1]; 418 | link.setAttribute('href', '#foo'); 419 | handler(mockEvent); 420 | expect(callbackCalled).toBe(true); 421 | }); 422 | 423 | test('options.preventDefault true (default)', () => { 424 | let callbackCalled = false; 425 | remove = linkHijacker.hijack({ root }, () => { 426 | callbackCalled = true; 427 | }); 428 | const handler = root.addEventListener.mock.calls[0][1]; 429 | link.setAttribute('href', '/foo'); 430 | handler(mockEvent); 431 | expect(callbackCalled).toBe(true); 432 | expect(mockEvent.preventDefault).toHaveBeenCalled(); 433 | }); 434 | 435 | test('options.preventDefault false', () => { 436 | let callbackCalled = false; 437 | remove = linkHijacker.hijack({ root, preventDefault: false }, () => { 438 | callbackCalled = true; 439 | }); 440 | const handler = root.addEventListener.mock.calls[0][1]; 441 | link.setAttribute('href', '/foo'); 442 | handler(mockEvent); 443 | expect(callbackCalled).toBe(true); 444 | expect(mockEvent.preventDefault).not.toHaveBeenCalled(); 445 | }); 446 | }); 447 | --------------------------------------------------------------------------------