├── .gitignore ├── LICENSE ├── README.md ├── demo ├── .env ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── 404.html │ ├── CNAME │ ├── favicon │ │ ├── green-grid-144-168-192-180x180.png │ │ ├── green-grid-144-168-192-512x512.png │ │ ├── green-grid-144-168-192.svg │ │ └── site.webmanifest │ └── index.html ├── src │ ├── App.test.tsx │ ├── App.tsx │ ├── index.tsx │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── stitches.config.ts │ └── ui │ │ ├── DarkModeButton.tsx │ │ └── Link.tsx └── tsconfig.json ├── package-lock.json ├── package.json ├── src ├── __mocks__ │ ├── maxTouchPointsOne.mock.ts │ ├── maxTouchPointsUndefined.mock.ts │ ├── maxTouchPointsZero.mock.ts │ ├── mqAllFalse.mock.ts │ ├── mqHybridChromebook.mock.ts │ ├── mqHybridPrimaryInputMouse.mock.ts │ ├── mqHybridPrimaryInputTouch.mock.ts │ ├── mqMatchMediaUndefined.mock.ts │ ├── mqMouseOnly.mock.ts │ ├── mqSamsungGalaxyNote.mock.ts │ ├── mqTouchOnly.mock.ts │ ├── onTouchStartInWindowFalse.mock.ts │ ├── onTouchStartInWindowTrue.mock.ts │ ├── passiveEventsFalse.mock.ts │ ├── passiveEventsTrue.mock.ts │ ├── pointerEventInWindowFalse.mock.ts │ ├── pointerEventInWindowTrue.mock.ts │ ├── screen414x896.mock.ts │ ├── screen768x1024.mock.ts │ ├── touchEventInWindowFalse.mock.ts │ ├── touchEventInWindowTrue.mock.ts │ ├── userAgentFirefoxWindows.mock.ts │ ├── userAgentIPad.mock.ts │ ├── userAgentIPhone.mock.ts │ └── userAgentMac.mock.ts ├── __tests__ │ ├── edgeCasesAndLegacyDevices │ │ ├── firefoxWindowsHybrid.test.ts │ │ ├── hybridChrombook.test.ts │ │ ├── hybridDesktopNoOnTouchStartInWindow.test.ts │ │ ├── iPad.test.ts │ │ ├── iPadRequestDesktopSite.test.ts │ │ ├── iPhone.test.ts │ │ ├── iPhoneRequestDesktopSite.test.ts │ │ ├── legacyDesktopComputer.test.ts │ │ ├── legacyTouchDevice.test.ts │ │ ├── microsoftHybridWithPointerEventsButNoTouchEventsPrimaryInputMouse.test.ts │ │ ├── microsoftHybridWithPointerEventsButNoTouchEventsPrimaryInputTouch.test.ts │ │ ├── mouseOnlyTouchEventInWindow.test.ts │ │ ├── noWindow.test.ts │ │ ├── samsungGalaxyNote.test.ts │ │ ├── touchOnlyNoPointerEvents.test.ts │ │ └── windowsComputerInTabletMode.test.ts │ ├── modernDeviceWithFullSupportForApis │ │ ├── hybridPrimaryInputMouse.test.ts │ │ ├── hybridPrimaryInputTouch.test.ts │ │ ├── mouseOnly.test.ts │ │ └── touchOnly.test.ts │ └── passiveEvents │ │ ├── noPassiveEventSupport.test.ts │ │ └── passiveEventSupport.test.ts └── index.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | coverage 4 | *gitigx* 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Rafael Pedicini 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 | # Detect It 2 | 3 | [![npm](https://img.shields.io/npm/dm/detect-it?label=npm)](https://www.npmjs.com/package/detect-it) [![npm bundle size (version)](https://img.shields.io/bundlephobia/minzip/detect-it?color=purple)](https://bundlephobia.com/result?p=detect-it) ![npm type definitions](https://img.shields.io/npm/types/detect-it?color=blue) 4 | 5 | - **[Live detection demo](https://detect-it.rafgraph.dev)** 6 | - Detect if a device is `mouseOnly`, `touchOnly`, or `hybrid` 7 | - Detect if the primary input is `mouse` or `touch` 8 | - Detect if the browser supports Pointer Events, Touch Events, and passive event listeners 9 | - You may also be interested in [Event From](https://github.com/rafgraph/event-from), which determines if a browser event was caused by `mouse`, `touch`, or `key` input 10 | 11 | 12 | Detect It's state is determined using multiple media query and API detections. Detect It uses the `hover` and `pointer` media queries, the Pointer Events API and max touch points detections, and two Touch Events API detections (browsers respond differently to each Touch Events API detection depending on the device 😩 welcome to WebDev). But now you don't have to worry about any of this, just let Detect It handle the details while you optimize your app for the type of device that's being used. 😁 13 | 14 | Detect It has been tested on numerous real world devices (since 2016), and the tests mock multiple devices and edge cases to ensure accurate results. The detection relies on how the browser presents the capabilities of the device as it is not possible to access the device hardware directly. 15 | 16 | --- 17 | 18 | [CDN option](#pre-built-cdn-option) ⚡️ [Recommended usage](#recommended-usage) ⚡️ [Device responsive UX](#device-responsive-ux) ⚡️ [Setting event listeners](#setting-event-listeners) ⚡️ [Detection details](#detection-details) 19 | 20 | --- 21 | 22 | ``` 23 | npm install --save detect-it 24 | ``` 25 | 26 | ```js 27 | import * as detectIt from 'detect-it'; 28 | // OR 29 | import { 30 | deviceType, 31 | primaryInput, 32 | supportsPointerEvents, 33 | supportsTouchEvents, 34 | supportsPassiveEvents, 35 | } from 'detect-it'; 36 | ``` 37 | 38 | ```js 39 | // types 40 | deviceType: 'mouseOnly' | 'touchOnly' | 'hybrid'; 41 | primaryInput: 'mouse' | 'touch'; 42 | supportsPointerEvents: boolean; 43 | supportsTouchEvents: boolean; 44 | supportsPassiveEvents: boolean; 45 | ``` 46 | 47 | --- 48 | 49 | ### `deviceType` 50 | 51 | **`mouseOnly` | `touchOnly` | `hybrid`** 52 | 53 | Indicates if the the device is `mouseOnly`, `touchOnly` or `hybrid`. For info on how the detection works and how specific devices are classified see the [Detection details](#detection-details) section. 54 | 55 | ```js 56 | import { deviceType } from 'detect-it'; 57 | 58 | if (deviceType === 'hybrid') { 59 | // ensure the site is usable by both mouse and touch input 60 | } 61 | ``` 62 | 63 | --- 64 | 65 | ### `primaryInput` 66 | 67 | **`mouse` | `touch`** 68 | 69 | Indicates if the primary input for the device is `mouse` or `touch`. For more info on how to use `primaryInput` see the [Recommended usage](#recommended-usage) section. 70 | 71 | ```js 72 | import { primaryInput } from 'detect-it'; 73 | 74 | if (primaryInput === 'touch') { 75 | // tailor UX for touch input 76 | } else { 77 | // tailor UX for mouse input 78 | } 79 | ``` 80 | 81 | --- 82 | 83 | ### `supportsPointerEvents` 84 | 85 | **`boolean`** 86 | 87 | Indicates if the browser supports the Pointer Events API. See [MDN's Pointer Events](https://developer.mozilla.org/en-US/docs/Web/API/Pointer_events) and the [W3C Pointer Events specification](https://www.w3.org/TR/pointerevents/) for more information on Pointer Events. See [Can I use](https://caniuse.com/mdn-api_pointerevent) for current support. 88 | 89 | ```js 90 | import { supportsPointerEvents } from 'detect-it'; 91 | 92 | if (supportsPointerEvents) { 93 | element.addEventListener('pointerenter', handlePointerEnter, false); 94 | } 95 | ``` 96 | 97 | --- 98 | 99 | ### `supportsTouchEvents` 100 | 101 | **`boolean`** 102 | 103 | Indicates if the browser supports the Touch Events API. See [MDN's Touch Events](https://developer.mozilla.org/en-US/docs/Web/API/Touch_events) and the [W3C Touch Events specification](https://w3c.github.io/touch-events/) for more information on Touch Events. 104 | 105 | ```js 106 | import { supportsTouchEvents } from 'detect-it'; 107 | 108 | if (supportsTouchEvents) { 109 | element.addEventListener('touchstart', handleTouchStart, false); 110 | } 111 | ``` 112 | 113 | --- 114 | 115 | ### `supportsPassiveEvents` 116 | 117 | **`boolean`** 118 | 119 | Indicates if the browser supports passive event listeners. See this [Passive Events Explainer](https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md) for more information on passive events. See [Can I use](https://caniuse.com/passive-event-listener) for current support. 120 | 121 | ```js 122 | import { supportsPassiveEvents } from 'detect-it'; 123 | 124 | if (supportsPassiveEvents) { 125 | // passive events are supported by the browser 126 | document.addEventListener('scroll', handleScroll, { 127 | capture: false, 128 | passive: true, 129 | }); 130 | } else { 131 | // passive events are not supported by the browser 132 | document.addEventListener('scroll', handleScroll, false); 133 | } 134 | ``` 135 | 136 | --- 137 | 138 | ## Pre-built CDN option 139 | 140 | Optionally, instead of using `npm install` you can load Detect It directly in the browser. A minified and production ready UMD version is available from the Unpkg CDN for this purpose. 141 | 142 | ```html 143 | 144 | 145 | ``` 146 | 147 | ```js 148 | // it will be available on the window as DetectIt 149 | if (window.DetectIt.primaryInput === 'touch') { 150 | // tailor UX for touch input 151 | } 152 | ``` 153 | 154 | --- 155 | 156 | ## Recommended usage 157 | 158 | TL;DR: 159 | 160 | - Use `primaryInput` to optimize the user experience for either `mouse` or `touch` input (note that the app should still be usable by both inputs). Use this along with classic responsive design that adapts to screen/window size to create a fully device responsive app. 161 | - Listening for user interactions: 162 | - If the browser `supportsPointerEvents` then only set Pointer Event listeners and use [`pointerType`](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/pointerType) to determine if the interaction was from `mouse` or `touch`. 163 | - Otherwise always set both Mouse Event and Touch Event listeners and use [Event From](https://github.com/rafgraph/event-from) to ignore Mouse Events generated from touch input. 164 | 165 | ### Device responsive UX 166 | 167 | Device responsive UX is about creating web apps that feel native on every device. This goes beyond classic responsive design, which only responds to the screen/window size, and includes how the user can interact with the app (the capabilities of the device). Can the user hover, swipe, long press, etc? 168 | 169 | There are 3 parts of device responsive UX: **Size** (size of screen/window), **Capabilities** (what the user can do/capabilities of the device), and **Interaction** (is the user hovering, touching, etc). **Size** and **Capabilities** need to be known at render time (when the UI is rendered before the user interacts with it), and **Interaction** needs to be known at interaction time (when the user is interacting with the app). 170 | 171 | - **Size** 172 | - This can be determined using media queries, for example `(max-width: 600px)`, either applied via CSS or in JavaScript by using something like [React Media](https://github.com/ReactTraining/react-media). 173 | - **Capabilities** 174 | - This is what **Detect It** is for - knowing at render time what the capabilities of the device are. There are a number of ways that you can use `deviceType` or `primaryInput` to optimize the UX for the capabilities of the device, however, in most cases I've found it makes sense to just use `primaryInput` and optimize the UX for `mouse` or `touch`, while ensuring that the app is still usable by both inputs. 175 | - Putting **Size** and **Capabilities** together, one approach is to optimize the UX for 4 scenarios: 176 | - Wide screen with `primaryInput` `mouse`: desktop/laptop with a normal window 177 | - Narrow screen and `primaryInput` `mouse`: desktop/laptop with a narrow window 178 | - Wide screen with `primaryInput` `touch`: tablet 179 | - Narrow screen with `primaryInput` `touch`: phone 180 | - **Interaction** 181 | - Is the user hovering, touching, etc. To help with this I created [React Interactive](https://github.com/rafgraph/react-interactive) which provides a callback for interactive state changes (`hover`, `mouseActive`, `touchActive`, `keyActive`) and allows you to style touch interactions in a way that feels native and is not possible with CSS pseudo classes. 182 | 183 | ### Setting event listeners 184 | 185 | Setting event listeners can be thought of as either setting Pointer Event listeners **_or_** setting Mouse Event and Touch Event listeners. Pointer Events can do everything that Mouse Events and Touch Events can do (and more), without having to worry about if a Mouse Event was caused by touch input and so should be ignored. It is generally preferred to use Pointer Events if they are supported. 186 | 187 | #### Pointer Event listeners 188 | 189 | If the browser `supportsPointerEvents` then only set Pointer Event listeners and use [`pointerType`](https://developer.mozilla.org/en-US/docs/Web/API/PointerEvent/pointerType) to determine if the interaction was from `mouse` or `touch`. 190 | 191 | ```js 192 | import { supportsPointerEvents } from 'detect-it'; 193 | 194 | const handlePointerEnter = (e) => { 195 | if (e.pointerType === 'mouse') { 196 | // event from mouse input 197 | } else { 198 | // event from touch input 199 | // note that pointerType can be 'mouse', 'touch' or 'pen' 200 | // but in most situations it makes it makes sense to treat 'touch' and 'pen' as the same 201 | } 202 | }; 203 | 204 | if (supportsPointerEvents) { 205 | element.addEventListener('pointerenter', handlePointerEnter, false); 206 | } else { 207 | // set mouse and touch event listeners 208 | } 209 | ``` 210 | 211 | #### Mouse Event and Touch Event listeners 212 | 213 | If the browser doesn't support Pointer Events, then there are a couple of ways to approach setting mouse and touch event listeners. 214 | 215 | > Note that a touch interaction will fire Touch Events as the interaction is in progress (touch on the screen), and will fire Mouse Events during a long press (extended touch on the screen), or after the touch interaction has finished (after the touch is removed from the screen) to support sites that only listen for Mouse Events. 216 | 217 | **Option 1**: If the device is `mouseOnly` or `touchOnly` then only set mouse or touch listeners, and if the device is `hybrid` set both mouse and touch event listeners and ignore Mouse Events caused by touch input (you can use [Event From](https://github.com/rafgraph/event-from) for this). 218 | 219 | **Option 2**: Always set both mouse and touch event listeners and use [Event From](https://github.com/rafgraph/event-from) to ignore Mouse Events from touch input. 220 | 221 | I prefer option 2 as it's simpler to code and I haven't noticed any performance impact from setting extra listeners (note that setting Touch Event listeners on a browser that doesn't support Touch Events is fine, the browser will just ignore the event listeners). 222 | 223 | ```js 224 | import { supportsPointerEvents } from 'detect-it'; 225 | import { eventFrom } from 'event-from'; 226 | 227 | const handleMouseEnter = (e) => { 228 | if (eventFrom(e) !== 'mouse') return; 229 | // code for handling mouse enter event from mouse input 230 | }; 231 | 232 | const handleTouchStart = (e) => { 233 | // code for handling touch start from touch input 234 | }; 235 | 236 | if (supportsPointerEvents) { 237 | // set pointer event listeners 238 | } else { 239 | // Pointer Events are not supported so set both Mouse Event and Touch Event listeners 240 | element.addEventListener('mouseenter', handleMouseEnter, false); 241 | element.addEventListener('touchstart', handleTouchStart, false); 242 | } 243 | ``` 244 | 245 | --- 246 | 247 | ## Detection details 248 | 249 | #### Determining the `deviceType` and `primaryInput` 250 | 251 | To determine the `deviceType` and `primaryInput` Detect It uses several media query and API detections to triangulate what type of device is being used. The entire detection is done when the script is imported so the results are known at render time (Detect It doesn't set any event listeners). 252 | 253 | Detect It uses the `hover` and `pointer` media queries, the Pointer Events API and max touch points detections, and two Touch Events API detections (browsers respond differently to each Touch Events API detection depending on the device). For more on this see the comments in the [source code](https://github.com/rafgraph/detect-it/blob/main/src/index.ts) for notes about detecting the device type and edge cases. 254 | 255 | #### Device tests and limitations 256 | 257 | Detect It has been tested on numerous real world devices (since 2016), and the [tests](https://github.com/rafgraph/detect-it/tree/main/src/__tests__) mock multiple devices and edge cases to ensure accurate results. However, these detections are limited by how the browser presents the capabilities of the device (the APIs it exposes and how it responds to media queries) so there are some limitations. For example, on an iPad it is impossible to tell if a mouse is connected, so Detect It always treats iPads as a `hybrid` device with `primaryInput` `touch`. 258 | 259 | In the case of a legacy browser or device that doesn't support the detections (e.g. no media query or Pointer Events support), Detect It will fall back to a default `mouseOnly` or `touchOnly` state. 260 | 261 | #### Hybrid device definition 262 | 263 | Detect It has a wide definition for what constitutes a `hybrid` device, or rather a strict definition for what are `mouseOnly` and `touchOnly` devices, because if a device strays from only a fine point and hover with a mouse, or a coarse touch with a finger, then it should be treated uniquely when considering how the user will interact with it. Below is the source code for determining `deviceType`: 264 | 265 | ```js 266 | // a hybrid device is one that both hasTouch and 267 | // any input can hover or has a fine pointer, or the primary pointer is not coarse 268 | // if it's not a hybrid, then if it hasTouch it's touchOnly, otherwise it's mouseOnly 269 | export const deviceType = 270 | hasTouch && (hasAnyHoverOrAnyFinePointer || !hasCoarsePrimaryPointer) 271 | ? 'hybrid' 272 | : hasTouch 273 | ? 'touchOnly' 274 | : 'mouseOnly'; 275 | ``` 276 | 277 | #### Some `hybrid` device examples 278 | 279 | - A touch capable Chromebook 280 | - A touch capable Windows computer (both when it's used as a regular computer, and when in tablet mode, e.g. Microsoft Surface without a keyboard) 281 | - A Samsung Galaxy Note with stylus 282 | - All iPads now that they support a mouse and keyboard (note that Apple makes it impossible to know if a mouse or keyboard is attached, so iPads are always treated as a `hybrid` with `primaryInput` `touch`) 283 | -------------------------------------------------------------------------------- /demo/.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | .eslintcache 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /demo/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Rafael Pedicini 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 | -------------------------------------------------------------------------------- /demo/README.md: -------------------------------------------------------------------------------- 1 | # Demo App for [`detect-it`](https://github.com/rafgraph/detect-it) 2 | 3 | Live demo app: https://detect-it.rafgraph.dev 4 | -------------------------------------------------------------------------------- /demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "detect-it-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "homepage": "https://detect-it.rafgraph.dev", 6 | "dependencies": { 7 | "@radix-ui/react-icons": "^1.0.3", 8 | "@stitches/react": "^0.1.9", 9 | "detect-it": "^4.0.1", 10 | "react": "^17.0.2", 11 | "react-dom": "^17.0.2", 12 | "react-interactive": "^1.0.0", 13 | "use-dark-mode": "^2.3.1" 14 | }, 15 | "devDependencies": { 16 | "@testing-library/jest-dom": "^5.12.0", 17 | "@testing-library/react": "^11.2.6", 18 | "@testing-library/user-event": "^13.1.8", 19 | "@types/jest": "^26.0.23", 20 | "@types/node": "^15.0.1", 21 | "@types/react": "^17.0.4", 22 | "@types/react-dom": "^17.0.3", 23 | "browserslist-config-css-grid": "^1.0.0", 24 | "gh-pages": "^3.1.0", 25 | "react-scripts": "^4.0.3", 26 | "typescript": "^4.2.4" 27 | }, 28 | "scripts": { 29 | "dev": "npm install --save ../ && npm start", 30 | "devCleanup": "npm install --save detect-it@latest", 31 | "deploy": "gh-pages --dist build --message \"Built from main branch `date +%Y%m%d`-`date +%H%M%S`\"", 32 | "predeploy": "npm run devCleanup && npm run lint && npm test -- --watchAll=false && npm run build", 33 | "lint": "eslint src", 34 | "start": "react-scripts start", 35 | "build": "react-scripts build", 36 | "test": "react-scripts test", 37 | "eject": "react-scripts eject" 38 | }, 39 | "eslintConfig": { 40 | "extends": [ 41 | "react-app", 42 | "react-app/jest" 43 | ] 44 | }, 45 | "browserslist": { 46 | "production": [ 47 | "last 2 versions or > 0.2% and not dead and extends browserslist-config-css-grid" 48 | ], 49 | "development": [ 50 | "last 1 chrome version", 51 | "last 1 firefox version", 52 | "last 1 safari version" 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /demo/public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 404 6 | 7 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /demo/public/CNAME: -------------------------------------------------------------------------------- 1 | detect-it.rafgraph.dev 2 | -------------------------------------------------------------------------------- /demo/public/favicon/green-grid-144-168-192-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafgraph/detect-it/a90c09ae774dab5e39a18ff597401c01bb73bca5/demo/public/favicon/green-grid-144-168-192-180x180.png -------------------------------------------------------------------------------- /demo/public/favicon/green-grid-144-168-192-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rafgraph/detect-it/a90c09ae774dab5e39a18ff597401c01bb73bca5/demo/public/favicon/green-grid-144-168-192-512x512.png -------------------------------------------------------------------------------- /demo/public/favicon/green-grid-144-168-192.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /demo/public/favicon/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "detect-it", 3 | "icons": [ 4 | { 5 | "src": "/favicon/green-grid-144-168-192-512x512.png", 6 | "sizes": "512x512", 7 | "type": "image/png" 8 | }, 9 | { 10 | "src": "/favicon/green-grid-144-168-192-180x180.png", 11 | "sizes": "180x180", 12 | "type": "image/png" 13 | }, 14 | { 15 | "src": "/favicon/green-grid-144-168-192.svg", 16 | "type": "image/svg+xml" 17 | } 18 | ], 19 | "theme_color": "#000000", 20 | "background_color": "#007800", 21 | "display": "browser" 22 | } 23 | -------------------------------------------------------------------------------- /demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 26 | Detect It 27 | 28 | 31 | 32 | 33 | 34 |
35 | 36 | 37 | -------------------------------------------------------------------------------- /demo/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react'; 2 | import { App } from './App'; 3 | 4 | describe('renders links', () => { 5 | const { container } = render(); 6 | const links = container.getElementsByTagName('a'); 7 | const hrefs = Object.values(links).map((link) => link.getAttribute('href')); 8 | 9 | test('renders link to detect-it', () => { 10 | expect(hrefs).toContain('https://github.com/rafgraph/detect-it'); 11 | }); 12 | }); 13 | -------------------------------------------------------------------------------- /demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | deviceType, 3 | primaryInput, 4 | supportsPointerEvents, 5 | supportsTouchEvents, 6 | supportsPassiveEvents, 7 | } from 'detect-it'; 8 | import { styled, globalCss } from './stitches.config'; 9 | import { DarkModeButton } from './ui/DarkModeButton'; 10 | import { Link } from './ui/Link'; 11 | 12 | const AppContainer = styled('div', { 13 | maxWidth: '400px', 14 | padding: '14px 15px 25px', 15 | margin: '0 auto', 16 | }); 17 | 18 | const HeaderContainer = styled('header', { 19 | display: 'flex', 20 | justifyContent: 'space-between', 21 | marginBottom: '6px', 22 | }); 23 | 24 | const H1 = styled('h1', { 25 | fontSize: '26px', 26 | marginRight: '16px', 27 | }); 28 | 29 | const LinkContainer = styled('div', { 30 | marginBottom: '20px', 31 | }); 32 | 33 | const H2 = styled('h2', { 34 | margin: '36px 0 -6px', 35 | fontSize: '22px', 36 | }); 37 | 38 | const CodeBlock = styled('div', { 39 | backgroundColor: '$codeBlockBackground', 40 | padding: '4px 16px 6px', 41 | borderRadius: '6px', 42 | margin: '16px 0', 43 | }); 44 | 45 | const Code = styled('code', { 46 | display: 'block', 47 | margin: '5px 0', 48 | }); 49 | 50 | const Result = styled('span', { 51 | color: '$blue', 52 | variants: { 53 | bool: { 54 | true: { 55 | color: '$green', 56 | }, 57 | false: { 58 | color: '$red', 59 | }, 60 | }, 61 | }, 62 | }); 63 | 64 | interface BooleanResultProps { 65 | bool: boolean; 66 | } 67 | const BooleanResult: React.FC = ({ bool }) => ( 68 | {`${bool}`} 69 | ); 70 | 71 | export const App = () => { 72 | globalCss(); 73 | 74 | return ( 75 | 76 | 77 |

Detect It – Live Detection

78 | 79 |
80 | 81 | 85 | https://github.com/rafgraph/detect-it 86 | 87 | 88 | 89 | 90 | deviceType: {deviceType} 91 | 92 | 93 | primaryInput: {primaryInput} 94 | 95 | 96 | supportsPointerEvents: 97 | 98 | 99 | supportsTouchEvents: 100 | 101 | 102 | supportsPassiveEvents: 103 | 104 | 105 | 106 |

Additional Device Info

107 | 108 | 109 | 110 | primaryPointerFine:{' '} 111 | 112 | 113 | 114 | anyPointerFine:{' '} 115 | 118 | 119 | 120 | primaryPointerCoarse:{' '} 121 | 124 | 125 | 126 | anyPointerCoarse:{' '} 127 | 130 | 131 | {/* 132 | primaryPointerNone:{' '} 133 | 134 | 135 | 136 | anyPointerNone:{' '} 137 | 140 | */} 141 | 142 | 143 | 144 | primaryHover:{' '} 145 | 146 | 147 | 148 | anyHover:{' '} 149 | 152 | 153 | 154 | primaryHoverNone:{' '} 155 | 156 | 157 | 158 | anyHoverNone:{' '} 159 | 162 | 163 | 164 | 165 | 166 | pointerEventInWindow:{' '} 167 | 168 | 169 | 170 | maxTouchPoints:{' '} 171 | {`${window.navigator.maxTouchPoints}`} 172 | 173 | 174 | 175 | 176 | onTouchStartInWindow:{' '} 177 | 178 | 179 | 180 | touchEventInWindow: 181 | 182 | 183 | 184 | 185 | screenSize:{' '} 186 | {`${window.screen.width}x${window.screen.height}`} 187 | 188 | 189 | 190 | 191 | userAgent: {window.navigator.userAgent} 192 | 193 | 194 |
195 | ); 196 | }; 197 | -------------------------------------------------------------------------------- /demo/src/index.tsx: -------------------------------------------------------------------------------- 1 | import { StrictMode } from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import { App } from './App'; 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById('root'), 10 | ); 11 | -------------------------------------------------------------------------------- /demo/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /demo/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | 7 | // stub matchMedia so tests run because it is not implemented in JSDOM 8 | Object.defineProperty(window, 'matchMedia', { 9 | value: jest.fn(() => ({ matches: true })), 10 | }); 11 | -------------------------------------------------------------------------------- /demo/src/stitches.config.ts: -------------------------------------------------------------------------------- 1 | import { createCss } from '@stitches/react'; 2 | 3 | export const { styled, css, global: createGlobalCss, theme } = createCss({ 4 | theme: { 5 | colors: { 6 | pageBackground: 'rgb(240,240,240)', 7 | codeBlockBackground: 'rgb(224,224,224)', 8 | highContrast: 'rgb(0,0,0)', 9 | lowContrast: 'rgb(128,128,128)', 10 | red: 'hsl(0,100%,45%)', 11 | green: 'hsl(120,100%,33%)', // same as rgb(0,168,0) 12 | blue: 'hsl(240,100%,50%)', 13 | purple: 'hsl(270,100%,60%)', 14 | }, 15 | fonts: { 16 | mono: 'monospace', 17 | }, 18 | }, 19 | }); 20 | 21 | export const darkThemeClass = theme({ 22 | colors: { 23 | pageBackground: 'rgb(32,32,32)', 24 | codeBlockBackground: 'rgb(56,56,56)', 25 | highContrast: 'rgb(192,192,192)', 26 | lowContrast: 'rgb(146,146,146)', 27 | red: 'hsl(0,100%,44%)', 28 | green: 'hsl(120,85%,42%)', 29 | blue: 'hsl(210,100%,60%)', 30 | purple: 'hsl(270,85%,60%)', 31 | }, 32 | }); 33 | 34 | export const globalCss = createGlobalCss({ 35 | // unset all styles on interactive elements 36 | 'button, input, select, textarea, a, area': { 37 | all: 'unset', 38 | }, 39 | // normalize behavior on all elements 40 | '*, *::before, *::after, button, input, select, textarea, a, area': { 41 | margin: 0, 42 | border: 0, 43 | padding: 0, 44 | boxSizing: 'inherit', 45 | font: 'inherit', 46 | fontWeight: 'inherit', 47 | textDecoration: 'inherit', 48 | textAlign: 'inherit', 49 | lineHeight: 'inherit', 50 | wordBreak: 'inherit', 51 | color: 'inherit', 52 | background: 'transparent', 53 | outline: 'none', 54 | WebkitTapHighlightColor: 'transparent', 55 | }, 56 | // set base styles for the app 57 | body: { 58 | color: '$highContrast', 59 | fontFamily: 'system-ui, Helvetica Neue, sans-serif', 60 | // use word-break instead of "overflow-wrap: anywhere" because of Safari support 61 | wordBreak: 'break-word', 62 | WebkitFontSmoothing: 'antialiased', 63 | MozOsxFontSmoothing: 'grayscale', 64 | fontSize: '16px', 65 | boxSizing: 'border-box', 66 | textSizeAdjust: 'none', 67 | }, 68 | code: { 69 | fontFamily: '$mono', 70 | }, 71 | // pass down height: 100% to the #root div 72 | 'body, html': { 73 | height: '100%', 74 | }, 75 | '#root': { 76 | minHeight: '100%', 77 | backgroundColor: '$pageBackground', 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /demo/src/ui/DarkModeButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Interactive } from 'react-interactive'; 3 | import { SunIcon } from '@radix-ui/react-icons'; 4 | import useDarkMode from 'use-dark-mode'; 5 | import { styled, darkThemeClass } from '../stitches.config'; 6 | 7 | const Button = styled(Interactive.Button, { 8 | color: '$highContrast', 9 | '&.hover, &.active': { 10 | color: '$green', 11 | borderColor: '$green', 12 | }, 13 | '&.focusFromKey': { 14 | boxShadow: '0 0 0 2px $colors$purple', 15 | }, 16 | }); 17 | 18 | export const DarkModeButton: React.VFC = (props) => { 19 | // put a try catch around localStorage so this app will work in codesandbox 20 | // when the user blocks third party cookies in chrome, 21 | // which results in a security error when useDarkMode tries to access localStorage 22 | // see https://github.com/codesandbox/codesandbox-client/issues/5397 23 | let storageProvider: any = null; 24 | try { 25 | storageProvider = localStorage; 26 | } catch {} 27 | const darkMode = useDarkMode(undefined, { 28 | classNameDark: darkThemeClass, 29 | storageProvider, 30 | }); 31 | 32 | // add color-scheme style to element 33 | // so document scroll bars will have native dark mode styling 34 | React.useEffect(() => { 35 | if (darkMode.value === true) { 36 | // @ts-ignore because colorScheme type not added yet 37 | document.documentElement.style.colorScheme = 'dark'; 38 | } else { 39 | // @ts-ignore 40 | document.documentElement.style.colorScheme = 'light'; 41 | } 42 | }, [darkMode.value]); 43 | 44 | return ( 45 | 60 | ); 61 | }; 62 | -------------------------------------------------------------------------------- /demo/src/ui/Link.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { Interactive } from 'react-interactive'; 3 | import { styled } from '../stitches.config'; 4 | 5 | const StyledLink = styled(Interactive.A, { 6 | color: '$lowContrast', 7 | textDecorationLine: 'underline', 8 | textDecorationStyle: 'solid', 9 | textDecorationColor: '$green', 10 | textDecorationThickness: 'from-font', 11 | padding: '2px 3px', 12 | margin: '-2px -3px', 13 | borderRadius: '3px', 14 | '&.hover, &.active': { 15 | color: '$highContrast', 16 | textDecorationColor: '$green', 17 | textDecorationStyle: 'solid', 18 | }, 19 | '&.focusFromKey': { 20 | boxShadow: '0 0 0 2px $colors$purple ', 21 | }, 22 | }); 23 | 24 | interface LinkProps extends React.ComponentPropsWithoutRef { 25 | newWindow?: boolean; 26 | } 27 | 28 | export const Link: React.VFC = ({ newWindow = true, ...props }) => ( 29 | 34 | ); 35 | -------------------------------------------------------------------------------- /demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "detect-it", 3 | "version": "4.0.1", 4 | "description": "Detect if a device is mouse only, touch only, or hybrid", 5 | "main": "dist/detect-it.cjs.js", 6 | "module": "dist/detect-it.esm.js", 7 | "types": "dist/index.d.ts", 8 | "sideEffects": false, 9 | "scripts": { 10 | "build": "rollpkg build --addUmdBuild", 11 | "watch": "rollpkg watch", 12 | "prepublishOnly": "npm run lint && npm test && npm run build", 13 | "lint": "eslint src", 14 | "test": "jest", 15 | "test:watch": "jest --watchAll", 16 | "coverage": "npx live-server coverage/lcov-report", 17 | "lintStaged": "lint-staged" 18 | }, 19 | "files": [ 20 | "dist" 21 | ], 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/rafgraph/detect-it.git" 25 | }, 26 | "keywords": [ 27 | "detect", 28 | "device", 29 | "mouse", 30 | "touch", 31 | "hybrid", 32 | "passive events", 33 | "pointer events" 34 | ], 35 | "author": "Rafael Pedicini ", 36 | "license": "MIT", 37 | "bugs": { 38 | "url": "https://github.com/rafgraph/detect-it/issues" 39 | }, 40 | "homepage": "https://detect-it.rafgraph.dev", 41 | "devDependencies": { 42 | "lint-staged": "^10.5.4", 43 | "pre-commit": "^1.2.2", 44 | "rollpkg": "^0.5.5", 45 | "typescript": "^4.2.4" 46 | }, 47 | "pre-commit": "lintStaged", 48 | "lint-staged": { 49 | "(src/**/*|demo/src/**/*)": [ 50 | "prettier --write --ignore-unknown" 51 | ] 52 | }, 53 | "prettier": "rollpkg/configs/prettier.json", 54 | "eslintConfig": { 55 | "extends": [ 56 | "./node_modules/rollpkg/configs/eslint" 57 | ], 58 | "rules": { 59 | "jest/no-mocks-import": "off" 60 | } 61 | }, 62 | "jest": { 63 | "preset": "rollpkg" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/__mocks__/maxTouchPointsOne.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window.navigator, 'maxTouchPoints', { 2 | value: 1, 3 | }); 4 | -------------------------------------------------------------------------------- /src/__mocks__/maxTouchPointsUndefined.mock.ts: -------------------------------------------------------------------------------- 1 | // for typescript to allow deletion of maxTouchPoints from window, 2 | // otherwise get this error: The operand of a 'delete' operator must be optional.ts(2790) 3 | interface navigatorOptionalMaxTouchPoints extends Omit { 4 | maxTouchPoints?: number; 5 | } 6 | interface windowOptionalMaxTouchPoints extends Omit { 7 | navigator: navigatorOptionalMaxTouchPoints; 8 | } 9 | 10 | const windowNoMaxTouchPoints = (window as unknown) as windowOptionalMaxTouchPoints; 11 | 12 | delete windowNoMaxTouchPoints.navigator.maxTouchPoints; 13 | -------------------------------------------------------------------------------- /src/__mocks__/maxTouchPointsZero.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window.navigator, 'maxTouchPoints', { 2 | value: 0, 3 | }); 4 | -------------------------------------------------------------------------------- /src/__mocks__/mqAllFalse.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'matchMedia', { 2 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 3 | value: jest.fn().mockImplementation((query) => ({ 4 | matches: false, 5 | })), 6 | }); 7 | -------------------------------------------------------------------------------- /src/__mocks__/mqHybridChromebook.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'matchMedia', { 2 | value: jest.fn().mockImplementation((query) => { 3 | switch (query) { 4 | case '(pointer: fine)': 5 | return { matches: true }; 6 | case '(any-pointer: fine)': 7 | return { matches: true }; 8 | 9 | case '(pointer: coarse)': 10 | return { matches: false }; 11 | case '(any-pointer: coarse)': 12 | return { matches: true }; 13 | 14 | case '(hover: hover)': 15 | return { matches: true }; 16 | case '(any-hover: hover)': 17 | return { matches: true }; 18 | 19 | case '(hover: none)': 20 | return { matches: false }; 21 | case '(any-hover: none)': 22 | return { matches: false }; 23 | 24 | default: 25 | return { matches: false }; 26 | } 27 | }), 28 | }); 29 | -------------------------------------------------------------------------------- /src/__mocks__/mqHybridPrimaryInputMouse.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'matchMedia', { 2 | value: jest.fn().mockImplementation((query) => { 3 | switch (query) { 4 | case '(pointer: fine)': 5 | return { matches: true }; 6 | case '(any-pointer: fine)': 7 | return { matches: true }; 8 | 9 | case '(pointer: coarse)': 10 | return { matches: false }; 11 | case '(any-pointer: coarse)': 12 | return { matches: true }; 13 | 14 | case '(hover: hover)': 15 | return { matches: true }; 16 | case '(any-hover: hover)': 17 | return { matches: true }; 18 | 19 | case '(hover: none)': 20 | return { matches: false }; 21 | case '(any-hover: none)': 22 | return { matches: true }; 23 | 24 | default: 25 | return { matches: false }; 26 | } 27 | }), 28 | }); 29 | -------------------------------------------------------------------------------- /src/__mocks__/mqHybridPrimaryInputTouch.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'matchMedia', { 2 | value: jest.fn().mockImplementation((query) => { 3 | switch (query) { 4 | case '(pointer: fine)': 5 | return { matches: false }; 6 | case '(any-pointer: fine)': 7 | return { matches: true }; 8 | 9 | case '(pointer: coarse)': 10 | return { matches: true }; 11 | case '(any-pointer: coarse)': 12 | return { matches: true }; 13 | 14 | case '(hover: hover)': 15 | return { matches: false }; 16 | case '(any-hover: hover)': 17 | return { matches: true }; 18 | 19 | case '(hover: none)': 20 | return { matches: true }; 21 | case '(any-hover: none)': 22 | return { matches: true }; 23 | 24 | default: 25 | return { matches: false }; 26 | } 27 | }), 28 | }); 29 | -------------------------------------------------------------------------------- /src/__mocks__/mqMatchMediaUndefined.mock.ts: -------------------------------------------------------------------------------- 1 | // for typescript to allow deletion of matchMedia from window, 2 | // otherwise get this error: The operand of a 'delete' operator must be optional.ts(2790) 3 | // because window.matchMedia is not typed as an optional property 4 | interface windowOptionalMatchMedia extends Omit { 5 | matchMedia?: (query: string) => MediaQueryList; 6 | } 7 | 8 | const windowNoMatchMedia = (window as unknown) as windowOptionalMatchMedia; 9 | 10 | delete windowNoMatchMedia.matchMedia; 11 | -------------------------------------------------------------------------------- /src/__mocks__/mqMouseOnly.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'matchMedia', { 2 | value: jest.fn().mockImplementation((query) => { 3 | switch (query) { 4 | case '(pointer: fine)': 5 | return { matches: true }; 6 | case '(any-pointer: fine)': 7 | return { matches: true }; 8 | 9 | case '(pointer: coarse)': 10 | return { matches: false }; 11 | case '(any-pointer: coarse)': 12 | return { matches: false }; 13 | 14 | case '(hover: hover)': 15 | return { matches: true }; 16 | case '(any-hover: hover)': 17 | return { matches: true }; 18 | 19 | case '(hover: none)': 20 | return { matches: false }; 21 | case '(any-hover: none)': 22 | return { matches: false }; 23 | 24 | default: 25 | return { matches: false }; 26 | } 27 | }), 28 | }); 29 | -------------------------------------------------------------------------------- /src/__mocks__/mqSamsungGalaxyNote.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'matchMedia', { 2 | value: jest.fn().mockImplementation((query) => { 3 | switch (query) { 4 | case '(pointer: fine)': 5 | return { matches: false }; 6 | case '(any-pointer: fine)': 7 | return { matches: true }; 8 | 9 | case '(pointer: coarse)': 10 | return { matches: true }; 11 | case '(any-pointer: coarse)': 12 | return { matches: true }; 13 | 14 | case '(hover: hover)': 15 | return { matches: false }; 16 | case '(any-hover: hover)': 17 | return { matches: true }; 18 | 19 | case '(hover: none)': 20 | return { matches: true }; 21 | case '(any-hover: none)': 22 | return { matches: true }; 23 | 24 | default: 25 | return { matches: false }; 26 | } 27 | }), 28 | }); 29 | -------------------------------------------------------------------------------- /src/__mocks__/mqTouchOnly.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'matchMedia', { 2 | value: jest.fn().mockImplementation((query) => { 3 | switch (query) { 4 | case '(pointer: fine)': 5 | return { matches: false }; 6 | case '(any-pointer: fine)': 7 | return { matches: false }; 8 | 9 | case '(pointer: coarse)': 10 | return { matches: true }; 11 | case '(any-pointer: coarse)': 12 | return { matches: true }; 13 | 14 | case '(hover: hover)': 15 | return { matches: false }; 16 | case '(any-hover: hover)': 17 | return { matches: false }; 18 | 19 | case '(hover: none)': 20 | return { matches: true }; 21 | case '(any-hover: none)': 22 | return { matches: true }; 23 | 24 | default: 25 | return { matches: false }; 26 | } 27 | }), 28 | }); 29 | -------------------------------------------------------------------------------- /src/__mocks__/onTouchStartInWindowFalse.mock.ts: -------------------------------------------------------------------------------- 1 | delete window.ontouchstart; 2 | -------------------------------------------------------------------------------- /src/__mocks__/onTouchStartInWindowTrue.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'ontouchstart', { 2 | value: jest.fn(), 3 | }); 4 | -------------------------------------------------------------------------------- /src/__mocks__/passiveEventsFalse.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'addEventListener', { 2 | value: jest.fn().mockImplementation((eventName, callback, useCapture) => { 3 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 4 | const capture = Boolean(useCapture); 5 | }), 6 | }); 7 | -------------------------------------------------------------------------------- /src/__mocks__/passiveEventsTrue.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'addEventListener', { 2 | value: jest.fn().mockImplementation((eventName, callback, options) => { 3 | // eslint-disable-next-line 4 | const passiveOption = options.passive; 5 | }), 6 | }); 7 | -------------------------------------------------------------------------------- /src/__mocks__/pointerEventInWindowFalse.mock.ts: -------------------------------------------------------------------------------- 1 | // for typescript to allow deletion of PointerEvent from window, 2 | // otherwise get this error: The operand of a 'delete' operator must be optional.ts(2790) 3 | // because window.PointerEvent is not typed as an optional property 4 | interface windowOptionalPointerEvent extends Omit { 5 | PointerEvent?: PointerEvent; 6 | } 7 | const windowNoPointerEvent = (window as unknown) as windowOptionalPointerEvent; 8 | 9 | delete windowNoPointerEvent.PointerEvent; 10 | -------------------------------------------------------------------------------- /src/__mocks__/pointerEventInWindowTrue.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'PointerEvent', { 2 | value: jest.fn(), 3 | }); 4 | -------------------------------------------------------------------------------- /src/__mocks__/screen414x896.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperties(window.screen, { 2 | width: { 3 | value: 414, 4 | }, 5 | height: { 6 | value: 896, 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /src/__mocks__/screen768x1024.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperties(window.screen, { 2 | width: { 3 | value: 768, 4 | }, 5 | height: { 6 | value: 1024, 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /src/__mocks__/touchEventInWindowFalse.mock.ts: -------------------------------------------------------------------------------- 1 | // for typescript to allow deletion of TouchEvent from window, 2 | // otherwise get this error: The operand of a 'delete' operator must be optional.ts(2790) 3 | // because window.TouchEvent is not typed as an optional property 4 | interface windowOptionalTouchEvent extends Omit { 5 | TouchEvent?: TouchEvent; 6 | } 7 | const windowNoTouchEvent = (window as unknown) as windowOptionalTouchEvent; 8 | 9 | delete windowNoTouchEvent.TouchEvent; 10 | -------------------------------------------------------------------------------- /src/__mocks__/touchEventInWindowTrue.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window, 'TouchEvent', { 2 | value: jest.fn(), 3 | }); 4 | -------------------------------------------------------------------------------- /src/__mocks__/userAgentFirefoxWindows.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window.navigator, 'userAgent', { 2 | value: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:81.0) Gecko/20100101 Firefox/81.0', 3 | }); 4 | -------------------------------------------------------------------------------- /src/__mocks__/userAgentIPad.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window.navigator, 'userAgent', { 2 | value: 3 | 'Mozilla/5.0 (iPad; CPU OS 14_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1', 4 | }); 5 | -------------------------------------------------------------------------------- /src/__mocks__/userAgentIPhone.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window.navigator, 'userAgent', { 2 | value: 3 | 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/604.1', 4 | }); 5 | -------------------------------------------------------------------------------- /src/__mocks__/userAgentMac.mock.ts: -------------------------------------------------------------------------------- 1 | Object.defineProperty(window.navigator, 'userAgent', { 2 | value: 3 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0 Safari/605.1.15', 4 | }); 5 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/firefoxWindowsHybrid.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowFalse.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqTouchOnly.mock'; 7 | import '../../__mocks__/userAgentFirefoxWindows.mock'; 8 | 9 | // bug in firefox (as of v81) on hybrid windows devices where the interaction media queries 10 | // always indicate a touch only device (only has a coarse pointer that can't hover) 11 | // so test to make sure that is treated as hybrid primary input mouse device 12 | 13 | import { 14 | deviceType, 15 | primaryInput, 16 | supportsPointerEvents, 17 | supportsTouchEvents, 18 | supportsPassiveEvents, 19 | } from '../../index'; 20 | 21 | test('deviceType: hybrid', () => { 22 | expect(deviceType).toBe('hybrid'); 23 | }); 24 | 25 | test('primaryInput: mouse', () => { 26 | expect(primaryInput).toBe('mouse'); 27 | }); 28 | 29 | test('supportsPointerEvents: true', () => { 30 | expect(supportsPointerEvents).toBe(true); 31 | }); 32 | 33 | test('supportsTouchEvents: true', () => { 34 | expect(supportsTouchEvents).toBe(true); 35 | }); 36 | 37 | test('supportsPassiveEvents: true', () => { 38 | expect(supportsPassiveEvents).toBe(true); 39 | }); 40 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/hybridChrombook.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowFalse.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqHybridChromebook.mock'; 7 | 8 | import { 9 | deviceType, 10 | primaryInput, 11 | supportsPointerEvents, 12 | supportsTouchEvents, 13 | supportsPassiveEvents, 14 | } from '../../index'; 15 | 16 | test('deviceType: hybrid', () => { 17 | expect(deviceType).toBe('hybrid'); 18 | }); 19 | 20 | test('primaryInput: mouse', () => { 21 | expect(primaryInput).toBe('mouse'); 22 | }); 23 | 24 | test('supportsPointerEvents: true', () => { 25 | expect(supportsPointerEvents).toBe(true); 26 | }); 27 | 28 | test('supportsTouchEvents: true', () => { 29 | expect(supportsTouchEvents).toBe(true); 30 | }); 31 | 32 | test('supportsPassiveEvents: true', () => { 33 | expect(supportsPassiveEvents).toBe(true); 34 | }); 35 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/hybridDesktopNoOnTouchStartInWindow.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowFalse.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqHybridPrimaryInputMouse.mock'; 7 | 8 | // onTouchStartInWindow is the old-old-legacy way to determine a touch device 9 | // and many websites interpreted it to mean that the device is a touch only phone, 10 | // so today browsers on a desktop/laptop computer with a touch screen (primary input mouse) 11 | // have onTouchStartInWindow as false (to prevent from being confused with a 12 | // touchOnly phone) even though they support the TouchEvents API 13 | 14 | import { 15 | deviceType, 16 | primaryInput, 17 | supportsPointerEvents, 18 | supportsTouchEvents, 19 | supportsPassiveEvents, 20 | } from '../../index'; 21 | 22 | test('deviceType: hybrid', () => { 23 | expect(deviceType).toBe('hybrid'); 24 | }); 25 | 26 | test('primaryInput: mouse', () => { 27 | expect(primaryInput).toBe('mouse'); 28 | }); 29 | 30 | test('supportsPointerEvents: true', () => { 31 | expect(supportsPointerEvents).toBe(true); 32 | }); 33 | 34 | test('supportsTouchEvents: true', () => { 35 | expect(supportsTouchEvents).toBe(true); 36 | }); 37 | 38 | test('supportsPassiveEvents: true', () => { 39 | expect(supportsPassiveEvents).toBe(true); 40 | }); 41 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/iPad.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowTrue.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqTouchOnly.mock'; 7 | import '../../__mocks__/userAgentIPad.mock'; 8 | import '../../__mocks__/screen768x1024.mock'; 9 | 10 | import { 11 | deviceType, 12 | primaryInput, 13 | supportsPointerEvents, 14 | supportsTouchEvents, 15 | supportsPassiveEvents, 16 | } from '../../index'; 17 | 18 | test('deviceType: hybrid', () => { 19 | expect(deviceType).toBe('hybrid'); 20 | }); 21 | 22 | test('primaryInput: touch', () => { 23 | expect(primaryInput).toBe('touch'); 24 | }); 25 | 26 | test('supportsPointerEvents: true', () => { 27 | expect(supportsPointerEvents).toBe(true); 28 | }); 29 | 30 | test('supportsTouchEvents: true', () => { 31 | expect(supportsTouchEvents).toBe(true); 32 | }); 33 | 34 | test('supportsPassiveEvents: true', () => { 35 | expect(supportsPassiveEvents).toBe(true); 36 | }); 37 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/iPadRequestDesktopSite.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowTrue.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqTouchOnly.mock'; 7 | import '../../__mocks__/userAgentMac.mock'; 8 | import '../../__mocks__/screen768x1024.mock'; 9 | 10 | import { 11 | deviceType, 12 | primaryInput, 13 | supportsPointerEvents, 14 | supportsTouchEvents, 15 | supportsPassiveEvents, 16 | } from '../../index'; 17 | 18 | test('deviceType: hybrid', () => { 19 | expect(deviceType).toBe('hybrid'); 20 | }); 21 | 22 | test('primaryInput: touch', () => { 23 | expect(primaryInput).toBe('touch'); 24 | }); 25 | 26 | test('supportsPointerEvents: true', () => { 27 | expect(supportsPointerEvents).toBe(true); 28 | }); 29 | 30 | test('supportsTouchEvents: true', () => { 31 | expect(supportsTouchEvents).toBe(true); 32 | }); 33 | 34 | test('supportsPassiveEvents: true', () => { 35 | expect(supportsPassiveEvents).toBe(true); 36 | }); 37 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/iPhone.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowTrue.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqTouchOnly.mock'; 7 | import '../../__mocks__/userAgentIPhone.mock'; 8 | import '../../__mocks__/screen414x896.mock'; 9 | 10 | import { 11 | deviceType, 12 | primaryInput, 13 | supportsPointerEvents, 14 | supportsTouchEvents, 15 | supportsPassiveEvents, 16 | } from '../../index'; 17 | 18 | test('deviceType: touchOnly', () => { 19 | expect(deviceType).toBe('touchOnly'); 20 | }); 21 | 22 | test('primaryInput: touch', () => { 23 | expect(primaryInput).toBe('touch'); 24 | }); 25 | 26 | test('supportsPointerEvents: true', () => { 27 | expect(supportsPointerEvents).toBe(true); 28 | }); 29 | 30 | test('supportsTouchEvents: true', () => { 31 | expect(supportsTouchEvents).toBe(true); 32 | }); 33 | 34 | test('supportsPassiveEvents: true', () => { 35 | expect(supportsPassiveEvents).toBe(true); 36 | }); 37 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/iPhoneRequestDesktopSite.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowTrue.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqTouchOnly.mock'; 7 | import '../../__mocks__/userAgentMac.mock'; 8 | import '../../__mocks__/screen414x896.mock'; 9 | 10 | import { 11 | deviceType, 12 | primaryInput, 13 | supportsPointerEvents, 14 | supportsTouchEvents, 15 | supportsPassiveEvents, 16 | } from '../../index'; 17 | 18 | test('deviceType: touchOnly', () => { 19 | expect(deviceType).toBe('touchOnly'); 20 | }); 21 | 22 | test('primaryInput: touch', () => { 23 | expect(primaryInput).toBe('touch'); 24 | }); 25 | 26 | test('supportsPointerEvents: true', () => { 27 | expect(supportsPointerEvents).toBe(true); 28 | }); 29 | 30 | test('supportsTouchEvents: true', () => { 31 | expect(supportsTouchEvents).toBe(true); 32 | }); 33 | 34 | test('supportsPassiveEvents: true', () => { 35 | expect(supportsPassiveEvents).toBe(true); 36 | }); 37 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/legacyDesktopComputer.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowFalse.mock'; 2 | import '../../__mocks__/maxTouchPointsUndefined.mock'; 3 | import '../../__mocks__/onTouchStartInWindowFalse.mock'; 4 | import '../../__mocks__/touchEventInWindowFalse.mock'; 5 | import '../../__mocks__/passiveEventsFalse.mock'; 6 | import '../../__mocks__/mqMatchMediaUndefined.mock'; 7 | 8 | // legacy mouse only computer that doesn't support Pointer Events or media queries 9 | 10 | import { 11 | deviceType, 12 | primaryInput, 13 | supportsPointerEvents, 14 | supportsTouchEvents, 15 | supportsPassiveEvents, 16 | } from '../../index'; 17 | 18 | test('deviceType: mouseOnly', () => { 19 | expect(deviceType).toBe('mouseOnly'); 20 | }); 21 | 22 | test('primaryInput: mouse', () => { 23 | expect(primaryInput).toBe('mouse'); 24 | }); 25 | 26 | test('supportsPointerEvents: false', () => { 27 | expect(supportsPointerEvents).toBe(false); 28 | }); 29 | 30 | test('supportsTouchEvents: false', () => { 31 | expect(supportsTouchEvents).toBe(false); 32 | }); 33 | 34 | test('supportsPassiveEvents: false', () => { 35 | expect(supportsPassiveEvents).toBe(false); 36 | }); 37 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/legacyTouchDevice.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowFalse.mock'; 2 | import '../../__mocks__/maxTouchPointsUndefined.mock'; 3 | import '../../__mocks__/onTouchStartInWindowTrue.mock'; 4 | import '../../__mocks__/touchEventInWindowFalse.mock'; 5 | import '../../__mocks__/passiveEventsFalse.mock'; 6 | import '../../__mocks__/mqMatchMediaUndefined.mock'; 7 | 8 | // legacy touch device that doesn't support Pointer Events or media queries 9 | 10 | import { 11 | deviceType, 12 | primaryInput, 13 | supportsPointerEvents, 14 | supportsTouchEvents, 15 | supportsPassiveEvents, 16 | } from '../../index'; 17 | 18 | test('deviceType: touchOnly', () => { 19 | expect(deviceType).toBe('touchOnly'); 20 | }); 21 | 22 | test('primaryInput: touch', () => { 23 | expect(primaryInput).toBe('touch'); 24 | }); 25 | 26 | test('supportsPointerEvents: false', () => { 27 | expect(supportsPointerEvents).toBe(false); 28 | }); 29 | 30 | test('supportsTouchEvents: true', () => { 31 | expect(supportsTouchEvents).toBe(true); 32 | }); 33 | 34 | test('supportsPassiveEvents: false', () => { 35 | expect(supportsPassiveEvents).toBe(false); 36 | }); 37 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/microsoftHybridWithPointerEventsButNoTouchEventsPrimaryInputMouse.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowFalse.mock'; 4 | import '../../__mocks__/touchEventInWindowFalse.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqHybridPrimaryInputMouse.mock'; 7 | 8 | // microsoft browsers before switching to chromium (IE and Edge <= v18) didn't support Touch Events 9 | 10 | import { 11 | deviceType, 12 | primaryInput, 13 | supportsPointerEvents, 14 | supportsTouchEvents, 15 | supportsPassiveEvents, 16 | } from '../../index'; 17 | 18 | test('deviceType: hybrid', () => { 19 | expect(deviceType).toBe('hybrid'); 20 | }); 21 | 22 | test('primaryInput: mouse', () => { 23 | expect(primaryInput).toBe('mouse'); 24 | }); 25 | 26 | test('supportsPointerEvents: true', () => { 27 | expect(supportsPointerEvents).toBe(true); 28 | }); 29 | 30 | test('supportsTouchEvents: false', () => { 31 | expect(supportsTouchEvents).toBe(false); 32 | }); 33 | 34 | test('supportsPassiveEvents: true', () => { 35 | expect(supportsPassiveEvents).toBe(true); 36 | }); 37 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/microsoftHybridWithPointerEventsButNoTouchEventsPrimaryInputTouch.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowFalse.mock'; 4 | import '../../__mocks__/touchEventInWindowFalse.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqHybridPrimaryInputTouch.mock'; 7 | 8 | // microsoft browsers before switching to chromium (IE and Edge <= v18) didn't support Touch Events 9 | 10 | import { 11 | deviceType, 12 | primaryInput, 13 | supportsPointerEvents, 14 | supportsTouchEvents, 15 | supportsPassiveEvents, 16 | } from '../../index'; 17 | 18 | test('deviceType: hybrid', () => { 19 | expect(deviceType).toBe('hybrid'); 20 | }); 21 | 22 | test('primaryInput: touch', () => { 23 | expect(primaryInput).toBe('touch'); 24 | }); 25 | 26 | test('supportsPointerEvents: true', () => { 27 | expect(supportsPointerEvents).toBe(true); 28 | }); 29 | 30 | test('supportsTouchEvents: false', () => { 31 | expect(supportsTouchEvents).toBe(false); 32 | }); 33 | 34 | test('supportsPassiveEvents: true', () => { 35 | expect(supportsPassiveEvents).toBe(true); 36 | }); 37 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/mouseOnlyTouchEventInWindow.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsZero.mock'; 3 | import '../../__mocks__/onTouchStartInWindowFalse.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqMouseOnly.mock'; 7 | 8 | // some browsers (chromium) support the TouchEvents API even when running on 9 | // a mouse only device (touchEventInWindow true, but onTouchStartInWindow false) 10 | 11 | import { 12 | deviceType, 13 | primaryInput, 14 | supportsPointerEvents, 15 | supportsTouchEvents, 16 | supportsPassiveEvents, 17 | } from '../../index'; 18 | 19 | test('deviceType: mouseOnly', () => { 20 | expect(deviceType).toBe('mouseOnly'); 21 | }); 22 | 23 | test('primaryInput: mouse', () => { 24 | expect(primaryInput).toBe('mouse'); 25 | }); 26 | 27 | test('supportsPointerEvents: true', () => { 28 | expect(supportsPointerEvents).toBe(true); 29 | }); 30 | 31 | test('supportsTouchEvents: false', () => { 32 | expect(supportsTouchEvents).toBe(false); 33 | }); 34 | 35 | test('supportsPassiveEvents: true', () => { 36 | expect(supportsPassiveEvents).toBe(true); 37 | }); 38 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/noWindow.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @jest-environment node 3 | */ 4 | // the above makes it so window is not defined (needs to be at the top of the file) 5 | // test to make sure detect-it doesn't error if no window 6 | // the below results are the default when no window 7 | 8 | import { 9 | deviceType, 10 | primaryInput, 11 | supportsPointerEvents, 12 | supportsTouchEvents, 13 | supportsPassiveEvents, 14 | } from '../../index'; 15 | 16 | test('deviceType: mouseOnly', () => { 17 | expect(deviceType).toBe('mouseOnly'); 18 | }); 19 | 20 | test('primaryInput: mouse', () => { 21 | expect(primaryInput).toBe('mouse'); 22 | }); 23 | 24 | test('supportsPointerEvents: false', () => { 25 | expect(supportsPointerEvents).toBe(false); 26 | }); 27 | 28 | test('supportsTouchEvents: false', () => { 29 | expect(supportsTouchEvents).toBe(false); 30 | }); 31 | 32 | test('supportsPassiveEvents: false', () => { 33 | expect(supportsPassiveEvents).toBe(false); 34 | }); 35 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/samsungGalaxyNote.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowTrue.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqSamsungGalaxyNote.mock'; 7 | 8 | import { 9 | deviceType, 10 | primaryInput, 11 | supportsPointerEvents, 12 | supportsTouchEvents, 13 | supportsPassiveEvents, 14 | } from '../../index'; 15 | 16 | test('deviceType: hybrid', () => { 17 | expect(deviceType).toBe('hybrid'); 18 | }); 19 | 20 | test('primaryInput: touch', () => { 21 | expect(primaryInput).toBe('touch'); 22 | }); 23 | 24 | test('supportsPointerEvents: true', () => { 25 | expect(supportsPointerEvents).toBe(true); 26 | }); 27 | 28 | test('supportsTouchEvents: true', () => { 29 | expect(supportsTouchEvents).toBe(true); 30 | }); 31 | 32 | test('supportsPassiveEvents: true', () => { 33 | expect(supportsPassiveEvents).toBe(true); 34 | }); 35 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/touchOnlyNoPointerEvents.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowFalse.mock'; 2 | import '../../__mocks__/maxTouchPointsUndefined.mock'; 3 | import '../../__mocks__/onTouchStartInWindowTrue.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqTouchOnly.mock'; 7 | 8 | // mainly iOS <= v13.1 (Pointer Events were introduced in iOS v13.2), but also some older android browsers 9 | 10 | import { 11 | deviceType, 12 | primaryInput, 13 | supportsPointerEvents, 14 | supportsTouchEvents, 15 | supportsPassiveEvents, 16 | } from '../../index'; 17 | 18 | test('deviceType: touchOnly', () => { 19 | expect(deviceType).toBe('touchOnly'); 20 | }); 21 | 22 | test('primaryInput: touch', () => { 23 | expect(primaryInput).toBe('touch'); 24 | }); 25 | 26 | test('supportsPointerEvents: false', () => { 27 | expect(supportsPointerEvents).toBe(false); 28 | }); 29 | 30 | test('supportsTouchEvents: true', () => { 31 | expect(supportsTouchEvents).toBe(true); 32 | }); 33 | 34 | test('supportsPassiveEvents: true', () => { 35 | expect(supportsPassiveEvents).toBe(true); 36 | }); 37 | -------------------------------------------------------------------------------- /src/__tests__/edgeCasesAndLegacyDevices/windowsComputerInTabletMode.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowFalse.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqTouchOnly.mock'; 7 | 8 | // media query is touch only, but no onTouchStartInWindow 9 | // because a mouse could added at any moment treat it as a hybrid 10 | 11 | import { 12 | deviceType, 13 | primaryInput, 14 | supportsPointerEvents, 15 | supportsTouchEvents, 16 | supportsPassiveEvents, 17 | } from '../../index'; 18 | 19 | test('deviceType: hybrid', () => { 20 | expect(deviceType).toBe('hybrid'); 21 | }); 22 | 23 | test('primaryInput: touch', () => { 24 | expect(primaryInput).toBe('touch'); 25 | }); 26 | 27 | test('supportsPointerEvents: true', () => { 28 | expect(supportsPointerEvents).toBe(true); 29 | }); 30 | 31 | test('supportsTouchEvents: true', () => { 32 | expect(supportsTouchEvents).toBe(true); 33 | }); 34 | 35 | test('supportsPassiveEvents: true', () => { 36 | expect(supportsPassiveEvents).toBe(true); 37 | }); 38 | -------------------------------------------------------------------------------- /src/__tests__/modernDeviceWithFullSupportForApis/hybridPrimaryInputMouse.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowTrue.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqHybridPrimaryInputMouse.mock'; 7 | 8 | import { 9 | deviceType, 10 | primaryInput, 11 | supportsPointerEvents, 12 | supportsTouchEvents, 13 | supportsPassiveEvents, 14 | } from '../../index'; 15 | 16 | test('deviceType: hybrid', () => { 17 | expect(deviceType).toBe('hybrid'); 18 | }); 19 | 20 | test('primaryInput: mouse', () => { 21 | expect(primaryInput).toBe('mouse'); 22 | }); 23 | 24 | test('supportsPointerEvents: true', () => { 25 | expect(supportsPointerEvents).toBe(true); 26 | }); 27 | 28 | test('supportsTouchEvents: true', () => { 29 | expect(supportsTouchEvents).toBe(true); 30 | }); 31 | 32 | test('supportsPassiveEvents: true', () => { 33 | expect(supportsPassiveEvents).toBe(true); 34 | }); 35 | -------------------------------------------------------------------------------- /src/__tests__/modernDeviceWithFullSupportForApis/hybridPrimaryInputTouch.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowTrue.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqHybridPrimaryInputTouch.mock'; 7 | 8 | import { 9 | deviceType, 10 | primaryInput, 11 | supportsPointerEvents, 12 | supportsTouchEvents, 13 | supportsPassiveEvents, 14 | } from '../../index'; 15 | 16 | test('deviceType: hybrid', () => { 17 | expect(deviceType).toBe('hybrid'); 18 | }); 19 | 20 | test('primaryInput: touch', () => { 21 | expect(primaryInput).toBe('touch'); 22 | }); 23 | 24 | test('supportsPointerEvents: true', () => { 25 | expect(supportsPointerEvents).toBe(true); 26 | }); 27 | 28 | test('supportsTouchEvents: true', () => { 29 | expect(supportsTouchEvents).toBe(true); 30 | }); 31 | 32 | test('supportsPassiveEvents: true', () => { 33 | expect(supportsPassiveEvents).toBe(true); 34 | }); 35 | -------------------------------------------------------------------------------- /src/__tests__/modernDeviceWithFullSupportForApis/mouseOnly.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsZero.mock'; 3 | import '../../__mocks__/onTouchStartInWindowFalse.mock'; 4 | import '../../__mocks__/touchEventInWindowFalse.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqMouseOnly.mock'; 7 | 8 | import { 9 | deviceType, 10 | primaryInput, 11 | supportsPointerEvents, 12 | supportsTouchEvents, 13 | supportsPassiveEvents, 14 | } from '../../index'; 15 | 16 | test('deviceType: mouseOnly', () => { 17 | expect(deviceType).toBe('mouseOnly'); 18 | }); 19 | 20 | test('primaryInput: mouse', () => { 21 | expect(primaryInput).toBe('mouse'); 22 | }); 23 | 24 | test('supportsPointerEvents: true', () => { 25 | expect(supportsPointerEvents).toBe(true); 26 | }); 27 | 28 | test('supportsTouchEvents: false', () => { 29 | expect(supportsTouchEvents).toBe(false); 30 | }); 31 | 32 | test('supportsPassiveEvents: true', () => { 33 | expect(supportsPassiveEvents).toBe(true); 34 | }); 35 | -------------------------------------------------------------------------------- /src/__tests__/modernDeviceWithFullSupportForApis/touchOnly.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/pointerEventInWindowTrue.mock'; 2 | import '../../__mocks__/maxTouchPointsOne.mock'; 3 | import '../../__mocks__/onTouchStartInWindowTrue.mock'; 4 | import '../../__mocks__/touchEventInWindowTrue.mock'; 5 | import '../../__mocks__/passiveEventsTrue.mock'; 6 | import '../../__mocks__/mqTouchOnly.mock'; 7 | 8 | import { 9 | deviceType, 10 | primaryInput, 11 | supportsPointerEvents, 12 | supportsTouchEvents, 13 | supportsPassiveEvents, 14 | } from '../../index'; 15 | 16 | test('deviceType: touchOnly', () => { 17 | expect(deviceType).toBe('touchOnly'); 18 | }); 19 | 20 | test('primaryInput: touch', () => { 21 | expect(primaryInput).toBe('touch'); 22 | }); 23 | 24 | test('supportsPointerEvents: true', () => { 25 | expect(supportsPointerEvents).toBe(true); 26 | }); 27 | 28 | test('supportsTouchEvents: true', () => { 29 | expect(supportsTouchEvents).toBe(true); 30 | }); 31 | 32 | test('supportsPassiveEvents: true', () => { 33 | expect(supportsPassiveEvents).toBe(true); 34 | }); 35 | -------------------------------------------------------------------------------- /src/__tests__/passiveEvents/noPassiveEventSupport.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/passiveEventsFalse.mock'; 2 | 3 | import { supportsPassiveEvents } from '../../index'; 4 | 5 | test('supportsPassiveEvents: false', () => { 6 | expect(supportsPassiveEvents).toBe(false); 7 | }); 8 | -------------------------------------------------------------------------------- /src/__tests__/passiveEvents/passiveEventSupport.test.ts: -------------------------------------------------------------------------------- 1 | import '../../__mocks__/passiveEventsTrue.mock'; 2 | 3 | import { supportsPassiveEvents } from '../../index'; 4 | 5 | test('supportsPassiveEvents: true', () => { 6 | expect(supportsPassiveEvents).toBe(true); 7 | }); 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | interface noWindow { 2 | screen: { 3 | width?: number; 4 | height?: number; 5 | }; 6 | navigator: { 7 | maxTouchPoints?: number; 8 | userAgent?: string; 9 | }; 10 | addEventListener?: () => void; 11 | removeEventListener?: () => void; 12 | matchMedia?: () => { matches: boolean }; 13 | } 14 | 15 | // so it doesn't throw if no window or matchMedia 16 | const w: Window | noWindow = 17 | typeof window !== 'undefined' ? window : { screen: {}, navigator: {} }; 18 | const matchMedia = (w.matchMedia || (() => ({ matches: false }))).bind(w); 19 | 20 | // passive events test 21 | // adapted from https://github.com/WICG/EventListenerOptions/blob/gh-pages/explainer.md 22 | let passiveOptionAccessed = false; 23 | const options = { 24 | get passive() { 25 | return (passiveOptionAccessed = true); 26 | }, 27 | }; 28 | // have to set and remove a no-op listener instead of null 29 | // (which was used previously), because Edge v15 throws an error 30 | // when providing a null callback. 31 | // https://github.com/rafgraph/detect-passive-events/pull/3 32 | // eslint-disable-next-line @typescript-eslint/no-empty-function 33 | const noop = () => {}; 34 | w.addEventListener && w.addEventListener('p', noop, options); 35 | w.removeEventListener && w.removeEventListener('p', noop, false); 36 | 37 | export const supportsPassiveEvents: boolean = passiveOptionAccessed; 38 | 39 | export const supportsPointerEvents: boolean = 'PointerEvent' in w; 40 | 41 | const onTouchStartInWindow = 'ontouchstart' in w; 42 | const touchEventInWindow = 'TouchEvent' in w; 43 | // onTouchStartInWindow is the old-old-legacy way to determine a touch device 44 | // and many websites interpreted it to mean that the device is a touch only phone, 45 | // so today browsers on a desktop/laptop computer with a touch screen (primary input mouse) 46 | // have onTouchStartInWindow as false (to prevent from being confused with a 47 | // touchOnly phone) even though they support the TouchEvents API, so need to check 48 | // both onTouchStartInWindow and touchEventInWindow for TouchEvent support, 49 | // however, some browsers (chromium) support the TouchEvents API even when running on 50 | // a mouse only device (touchEventInWindow true, but onTouchStartInWindow false) 51 | // so the touchEventInWindow check needs to include an coarse pointer media query 52 | export const supportsTouchEvents: boolean = 53 | onTouchStartInWindow || 54 | (touchEventInWindow && matchMedia('(any-pointer: coarse)').matches); 55 | 56 | const hasTouch = (w.navigator.maxTouchPoints || 0) > 0 || supportsTouchEvents; 57 | 58 | // userAgent is used as a backup to correct for known device/browser bugs 59 | // and when the browser doesn't support interaction media queries (pointer and hover) 60 | // see https://caniuse.com/css-media-interaction 61 | const userAgent = w.navigator.userAgent || ''; 62 | 63 | // iPads now support a mouse that can hover, however the media query interaction 64 | // feature results always say iPads only have a coarse pointer that can't hover 65 | // even when a mouse is connected (anyFine and anyHover are always false), 66 | // this unfortunately indicates a touch only device but iPads should 67 | // be classified as a hybrid device, so determine if it is an iPad 68 | // to indicate it should be treated as a hybrid device with anyHover true 69 | const isIPad = 70 | matchMedia('(pointer: coarse)').matches && 71 | // both iPad and iPhone can "request desktop site", which sets the userAgent to Macintosh 72 | // so need to check both userAgents to determine if it is an iOS device 73 | // and screen size to separate iPad from iPhone 74 | /iPad|Macintosh/.test(userAgent) && 75 | Math.min(w.screen.width || 0, w.screen.height || 0) >= 768; 76 | 77 | const hasCoarsePrimaryPointer = 78 | (matchMedia('(pointer: coarse)').matches || 79 | // if the pointer is not coarse and not fine then the browser doesn't support 80 | // interaction media queries (see https://caniuse.com/css-media-interaction) 81 | // so if it has onTouchStartInWindow assume it has a coarse primary pointer 82 | (!matchMedia('(pointer: fine)').matches && onTouchStartInWindow)) && 83 | // bug in firefox (as of v81) on hybrid windows devices where the interaction media queries 84 | // always indicate a touch only device (only has a coarse pointer that can't hover) 85 | // so assume that the primary pointer is not coarse for firefox windows 86 | !/Windows.*Firefox/.test(userAgent); 87 | 88 | const hasAnyHoverOrAnyFinePointer = 89 | matchMedia('(any-pointer: fine)').matches || 90 | matchMedia('(any-hover: hover)').matches || 91 | // iPads might have an input device that can hover, so assume it has anyHover 92 | isIPad || 93 | // if no onTouchStartInWindow then the browser is indicating that it is not a touch only device 94 | // see above note for supportsTouchEvents 95 | !onTouchStartInWindow; 96 | 97 | // a hybrid device is one that both hasTouch and 98 | // any input can hover or has a fine pointer, or the primary pointer is not coarse 99 | // if it's not a hybrid, then if it hasTouch it's touchOnly, otherwise it's mouseOnly 100 | export const deviceType: 'mouseOnly' | 'touchOnly' | 'hybrid' = 101 | hasTouch && (hasAnyHoverOrAnyFinePointer || !hasCoarsePrimaryPointer) 102 | ? 'hybrid' 103 | : hasTouch 104 | ? 'touchOnly' 105 | : 'mouseOnly'; 106 | 107 | export const primaryInput: 'mouse' | 'touch' = 108 | deviceType === 'mouseOnly' 109 | ? 'mouse' 110 | : deviceType === 'touchOnly' 111 | ? 'touch' 112 | : // if the device is a hybrid, then if the primary pointer is coarse 113 | // assume the primaryInput is touch, otherwise assume it's mouse 114 | hasCoarsePrimaryPointer 115 | ? 'touch' 116 | : 'mouse'; 117 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "rollpkg/configs/tsconfig.json", 3 | "compilerOptions": { 4 | "target": "ES5" 5 | } 6 | } 7 | --------------------------------------------------------------------------------