├── .all-contributorsrc ├── .editorconfig ├── .eslintrc.json ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ ├── feature-request.md │ └── question.md └── PULL_REQUEST_TEMPLATE │ └── pull-request-template.md ├── .gitignore ├── .prettierrc.json ├── .travis.yml ├── .vscode ├── extensions.json └── settings.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── jest.config.js ├── lerna.json ├── package.json ├── packages ├── state-hooks │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── README.md │ ├── documentation.yml │ ├── package.json │ ├── src │ │ ├── index.ts │ │ ├── useChanging.test.ts │ │ ├── useChanging.ts │ │ ├── usePrevious.test.ts │ │ ├── usePrevious.ts │ │ ├── useTimeline.test.ts │ │ ├── useTimeline.ts │ │ ├── useToggle.test.ts │ │ ├── useToggle.ts │ │ ├── useUndoable.test.ts │ │ ├── useUndoable.ts │ │ └── utils.ts │ └── tsconfig.json └── web-api-hooks │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── README.md │ ├── documentation.yml │ ├── package.json │ ├── src │ ├── experimental-types │ │ ├── NetworkInformation.bundled.ts │ │ ├── NetworkInformation.d.ts │ │ └── VisualViewport.d.ts │ ├── index.ts │ ├── ssr.test.tsx │ ├── types.ts │ ├── useColorSchemePreference.ts │ ├── useDeviceMotion.test.ts │ ├── useDeviceMotion.ts │ ├── useDeviceOrientation.test.ts │ ├── useDeviceOrientation.ts │ ├── useDocumentReadiness.test.ts │ ├── useDocumentReadiness.ts │ ├── useDocumentVisibility.test.ts │ ├── useDocumentVisibility.ts │ ├── useEventListener.ts │ ├── useFocus.ts │ ├── useGeolocation.ts │ ├── useHover.ts │ ├── useInterval.test.ts │ ├── useInterval.ts │ ├── useLanguagePreferences.test.ts │ ├── useLanguagePreferences.ts │ ├── useLocalStorage.ts │ ├── useMedia.test.ts │ ├── useMedia.ts │ ├── useMotionPreference.ts │ ├── useMouseCoords.test.ts │ ├── useMouseCoords.ts │ ├── useNetworkAvailability.test.ts │ ├── useNetworkAvailability.ts │ ├── useNetworkInformation.ts │ ├── useSessionStorage.ts │ ├── useSize.ts │ ├── useStorage.ts │ ├── useViewportScale.ts │ ├── useViewportScrollCoords.ts │ ├── useViewportSize.ts │ ├── useWindowScrollCoords.test.ts │ ├── useWindowScrollCoords.ts │ ├── useWindowSize.test.ts │ ├── useWindowSize.ts │ └── utils.ts │ └── tsconfig.json ├── tsconfig.json └── yarn.lock /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "projectName": "react-hooks", 3 | "projectOwner": "kripod", 4 | "repoType": "github", 5 | "repoHost": "https://github.com", 6 | "files": [ 7 | "README.md" 8 | ], 9 | "commitConvention": "none", 10 | "contributorsPerLine": 7, 11 | "contributors": [ 12 | { 13 | "login": "kripod", 14 | "name": "Kristóf Poduszló", 15 | "avatar_url": "https://avatars3.githubusercontent.com/u/14854048?v=4", 16 | "profile": "https://github.com/kripod", 17 | "contributions": [ 18 | "maintenance", 19 | "code", 20 | "test", 21 | "doc", 22 | "example", 23 | "ideas", 24 | "infra" 25 | ] 26 | }, 27 | { 28 | "login": "gaearon", 29 | "name": "Dan Abramov", 30 | "avatar_url": "https://avatars0.githubusercontent.com/u/810438?v=4", 31 | "profile": "http://twitter.com/dan_abramov", 32 | "contributions": [ 33 | "code", 34 | "blog", 35 | "ideas", 36 | "tutorial" 37 | ] 38 | }, 39 | { 40 | "login": "donavon", 41 | "name": "Donavon West", 42 | "avatar_url": "https://avatars3.githubusercontent.com/u/887639?v=4", 43 | "profile": "https://donavon.com", 44 | "contributions": [ 45 | "test" 46 | ] 47 | }, 48 | { 49 | "login": "prsnnami", 50 | "name": "Prasanna Mishra", 51 | "avatar_url": "https://avatars1.githubusercontent.com/u/11041007?v=4", 52 | "profile": "https://github.com/prsnnami", 53 | "contributions": [ 54 | "doc" 55 | ] 56 | }, 57 | { 58 | "login": "Jordan-Gilliam", 59 | "name": "Nolansym", 60 | "avatar_url": "https://avatars0.githubusercontent.com/u/25993686?v=4", 61 | "profile": "https://github.com/Jordan-Gilliam", 62 | "contributions": [ 63 | "example" 64 | ] 65 | }, 66 | { 67 | "login": "cmoog", 68 | "name": "Charles Moog", 69 | "avatar_url": "https://avatars1.githubusercontent.com/u/7585078?v=4", 70 | "profile": "https://github.com/cmoog", 71 | "contributions": [ 72 | "code", 73 | "test", 74 | "doc", 75 | "example" 76 | ] 77 | }, 78 | { 79 | "login": "mjackson", 80 | "name": "Michael Jackson", 81 | "avatar_url": "https://avatars0.githubusercontent.com/u/92839?v=4", 82 | "profile": "https://mjackson.me", 83 | "contributions": [ 84 | "ideas" 85 | ] 86 | }, 87 | { 88 | "login": "Jfelix61", 89 | "name": "Jose Felix ", 90 | "avatar_url": "https://avatars2.githubusercontent.com/u/21092519?v=4", 91 | "profile": "http://jfelix.info", 92 | "contributions": [ 93 | "infra", 94 | "code" 95 | ] 96 | }, 97 | { 98 | "login": "Davide-Gheri", 99 | "name": "Davide Gheri", 100 | "avatar_url": "https://avatars2.githubusercontent.com/u/24524678?v=4", 101 | "profile": "https://github.com/Davide-Gheri", 102 | "contributions": [ 103 | "bug" 104 | ] 105 | } 106 | ] 107 | } 108 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { "browser": true }, 4 | "parserOptions": { "project": "./tsconfig.json" }, 5 | "extends": [ 6 | "airbnb-typescript", 7 | "airbnb/hooks", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended", 10 | "plugin:jest/recommended", 11 | "plugin:jsdoc/recommended", 12 | "plugin:testing-library/recommended", 13 | "plugin:testing-library/react", 14 | "prettier", 15 | "prettier/@typescript-eslint", 16 | "prettier/react" 17 | ], 18 | "plugins": ["jsdoc", "simple-import-sort"], 19 | 20 | "rules": { 21 | // Auto-sort imports 22 | "sort-imports": "off", 23 | "import/order": "off", 24 | "simple-import-sort/sort": "error", 25 | 26 | // Using a type system makes these options safe 27 | "react/jsx-props-no-spreading": "off", 28 | "jsdoc/check-tag-names": ["warn", { "definedTags": ["jest-environment"] }], 29 | "jsdoc/no-undefined-types": "off", 30 | "jsdoc/require-jsdoc": "off", 31 | "jsdoc/require-param-type": "off", 32 | "jsdoc/require-returns-type": "off" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 🐞 3 | about: Report anything that isn't working as expected to help us improve 4 | labels: bug 5 | --- 6 | 7 | ## Description 8 | 9 | 10 | 11 | ## Reproduction 12 | 13 | 14 | 15 | ## Expected behavior 16 | 17 | 18 | 19 | ## Actual behavior 20 | 21 | 22 | 23 | ## Environment 24 | 25 | 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 💡 3 | about: Suggest a new idea for the project 4 | labels: enhancement 5 | --- 6 | 7 | ## Motivation 8 | 9 | 10 | 11 | ## Basic example 12 | 13 | 14 | 15 | ## Details 16 | 17 | 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 🙋 3 | about: Usage question or discussion about the project 4 | labels: question 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE/pull-request-template.md: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log* 2 | .env 3 | .eslintcache 4 | coverage/ 5 | node_modules/ 6 | pkg/ 7 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | os: 3 | - linux 4 | - windows 5 | - osx 6 | node_js: 7 | - 12 8 | - 14 9 | env: 10 | - YARN_GPG=no 11 | 12 | script: 13 | - yarn lint 14 | - yarn build 15 | - yarn test --coverage 16 | after_success: 17 | - npx codecov 18 | 19 | deploy: 20 | - provider: npm 21 | edge: true 22 | run_script: publish 23 | on: 24 | tags: true 25 | condition: $TRAVIS_OS_NAME = linux 26 | node_js: 12 27 | - provider: npm 28 | edge: true 29 | run_script: publish 30 | registry: https://npm.pkg.github.com/@kripod 31 | api_token: $GH_TOKEN 32 | on: 33 | tags: true 34 | condition: $TRAVIS_OS_NAME = linux 35 | node_js: 12 36 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "EditorConfig.EditorConfig", 5 | "esbenp.prettier-vscode" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | // Enable automatic file formatting and fixing 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true, 5 | "editor.codeActionsOnSave": { 6 | "source.fixAll.eslint": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | - Demonstrating empathy and kindness toward other people 21 | - Being respectful of differing opinions, viewpoints, and experiences 22 | - Giving and gracefully accepting constructive feedback 23 | - Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | - Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | - The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | - Trolling, insulting or derogatory comments, and personal or political attacks 33 | - Public or private harassment 34 | - Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | - Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | kripod@protonmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for taking the time to contribute! Guidelines below are meant to help you along the way. All contributions are welcome, including ideas, tweaks and more. 4 | 5 | ## Code of Conduct 6 | 7 | This project is governed by the [Contributor Covenant Code of Conduct](./CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code. 8 | 9 | ## Proposing a change 10 | 11 | Before making a non-trivial change, please discuss it via [issues]. You should begin the title with _[useHookName]_ if applicable. 12 | 13 | ## Development process 14 | 15 | Please keep the scope of each pull request to **one** specific feature or fix. 16 | 17 | ### Prequisites 18 | 19 | - [Node.js](https://nodejs.org/) >=10.13 20 | - [Yarn](https://yarnpkg.com/) >=1 21 | 22 | ### Workflow 23 | 24 | 0. Assign related [issues] to yourself 25 | 1. Clone a fork of the `master` branch and install all the required dependencies with `yarn` 26 | 1. Make changes to the codebase 27 | 1. Before pushing, fix any errors possibly emitted by the following commands: 28 | 29 | - `yarn format` fixes stylistic issues using [Prettier] 30 | - `yarn lint` enforces coding rules based on the [Airbnb JavaScript Style Guide] 31 | - `yarn test` runs tests found in '\*.test.ts(x)' files 32 | 33 | 1. If you made documentation changes, then update `documentation.yml` and run `yarn doc` 34 | 1. Record your changes with `yarn commit`, adhering to the [Conventional Commits] specification 35 | 1. Open a new pull request, [referencing corresponding issues] if available 36 | 37 | ## License 38 | 39 | As a collaborator, you agree to license your contributions under the project's [MIT license](./LICENSE). 40 | 41 | [issues]: https://github.com/kripod/react-hooks/issues 42 | [prettier]: https://prettier.io/ 43 | [airbnb javascript style guide]: https://github.com/airbnb/javascript 44 | [conventional commits]: https://www.conventionalcommits.org/en/v1.0.0/ 45 | [referencing corresponding issues]: https://help.github.com/en/github/managing-your-work-on-github/linking-a-pull-request-to-an-issue#linking-a-pull-request-to-an-issue-using-a-keyword 46 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019-2020 Kristóf Poduszló 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 | # @kripod/react-hooks 2 | 3 | Essential set of [React Hooks] for convenient [Web API] consumption and state management. 4 | 5 | [![Travis (.com)](https://img.shields.io/travis/com/kripod/react-hooks)](https://travis-ci.com/github/kripod/react-hooks) 6 | [![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/kripod/react-hooks.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/kripod/react-hooks/context:javascript) 7 | [![Codecov](https://img.shields.io/codecov/c/github/kripod/react-hooks)](https://codecov.io/gh/kripod/react-hooks) 8 | [![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/) 9 | [![lerna](https://img.shields.io/badge/maintained%20with-lerna-cc00ff.svg)](https://lerna.js.org/) 10 | 11 | [react hooks]: https://reactjs.org/docs/hooks-intro.html 12 | [web api]: https://developer.mozilla.org/docs/Web/API 13 | 14 | ## Key features 15 | 16 | - 🌳 **Bundler-friendly** with tree shaking support 17 | - 📚 **Well-documented** and type-safe interfaces 18 | - ⚛️ **Zero-config** server-side rendering capability 19 | - 📦 **Self-contained**, free of runtime dependencies 20 | 21 | ## Project structure 22 | 23 | Being composed of multiple packages, this project is managed as a [monorepo][]. Please see the documentation of each package for further details about them: 24 | 25 | - [state-hooks](https://github.com/kripod/react-hooks/tree/master/packages/state-hooks) 26 | - [web-api-hooks](https://github.com/kripod/react-hooks/tree/master/packages/web-api-hooks) 27 | 28 | [monorepo]: https://gomonorepo.org/ 29 | 30 | ## Contributing 31 | 32 | Thanks for being interested in contributing! Please read our [contribution guidelines](./CONTRIBUTING.md) to get started. 33 | 34 | ### Contributors ✨ 35 | 36 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 |

Kristóf Poduszló

🚧 💻 ⚠️ 📖 💡 🤔 🚇

Dan Abramov

💻 📝 🤔

Donavon West

⚠️

Prasanna Mishra

📖

Nolansym

💡

Charles Moog

💻 ⚠️ 📖 💡

Michael Jackson

🤔

Jose Felix

🚇 💻

Davide Gheri

🐛
56 | 57 | 58 | 59 | 60 | 61 | 62 | This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome! 63 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | }; 4 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "independent", 3 | "npmClient": "yarn", 4 | "useWorkspaces": true, 5 | "command": { 6 | "version": { 7 | "conventionalCommits": true, 8 | "changelogPreset": "conventionalcommits", 9 | "message": "chore(release): publish" 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "workspaces": [ 4 | "packages/*" 5 | ], 6 | "scripts": { 7 | "build": "lerna run build", 8 | "clean": "git clean --force -dX", 9 | "commit": "git-cz", 10 | "doc": "lerna run doc && all-contributors generate", 11 | "format": "prettier --ignore-path .gitignore --write .", 12 | "postinstall": "lerna bootstrap", 13 | "lint": "eslint --ignore-path .gitignore \"**/*.{ts,tsx,js}\"", 14 | "publish": "lerna publish from-git --contents pkg --yes", 15 | "release": "dotenv -- lerna version --create-release github", 16 | "test": "jest", 17 | "type-check": "tsc" 18 | }, 19 | "husky": { 20 | "hooks": { 21 | "pre-commit": "lint-staged", 22 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 23 | } 24 | }, 25 | "commitlint": { 26 | "extends": [ 27 | "@commitlint/config-conventional" 28 | ], 29 | "rules": { 30 | "scope-case": [ 31 | 2, 32 | "always", 33 | "camel-case" 34 | ] 35 | } 36 | }, 37 | "lint-staged": { 38 | "*.{ts,tsx,js,json,yml,md}": "prettier --write", 39 | "*.{ts,tsx,js}": "eslint --fix" 40 | }, 41 | "config": { 42 | "commitizen": { 43 | "path": "@commitlint/prompt" 44 | } 45 | }, 46 | "devDependencies": { 47 | "@commitlint/cli": "^8.2.0", 48 | "@commitlint/config-conventional": "^8.2.0", 49 | "@commitlint/prompt": "^8.2.0", 50 | "@pika/pack": "^0.5.0", 51 | "@pika/plugin-build-node": "^0.8.3", 52 | "@pika/plugin-build-web": "^0.8.3", 53 | "@pika/plugin-ts-standard-pkg": "^0.8.3", 54 | "@testing-library/react": "^10.0.4", 55 | "@testing-library/react-hooks": "^3.1.0", 56 | "@types/jest": "^25.2.3", 57 | "@types/react": "^16.9.35", 58 | "@typescript-eslint/eslint-plugin": "^2.26.0", 59 | "@typescript-eslint/parser": "^2.26.0", 60 | "all-contributors-cli": "^6.15.0", 61 | "commitizen": "^4.1.2", 62 | "documentation": "^13.0.0", 63 | "dotenv-cli": "^3.0.0", 64 | "eslint": "^6.4.0", 65 | "eslint-config-airbnb": "^18.1.0", 66 | "eslint-config-airbnb-typescript": "^7.2.1", 67 | "eslint-config-prettier": "^6.11.0", 68 | "eslint-plugin-import": "^2.20.2", 69 | "eslint-plugin-jest": "^23.13.2", 70 | "eslint-plugin-jsdoc": "^26.0.1", 71 | "eslint-plugin-jsx-a11y": "^6.2.3", 72 | "eslint-plugin-react": "^7.20.0", 73 | "eslint-plugin-react-hooks": "^4.0.4", 74 | "eslint-plugin-simple-import-sort": "^5.0.3", 75 | "eslint-plugin-testing-library": "^3.1.4", 76 | "husky": "^4.2.5", 77 | "jest": "^26.0.1", 78 | "lerna": "^3.22.0", 79 | "lint-staged": "^10.2.7", 80 | "prettier": "^2.0.5", 81 | "prettier-plugin-packagejson": "^2.2.5", 82 | "react": "^16.13.1", 83 | "react-dom": "^16.13.1", 84 | "react-test-renderer": "^16.13.1", 85 | "ts-jest": "^26.1.0", 86 | "typescript": "^3.9.3" 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /packages/state-hooks/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | rules: { 5 | 'import/no-extraneous-dependencies': [ 6 | 'error', 7 | { packageDir: [__dirname, path.join(__dirname, '../..')] }, 8 | ], 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/state-hooks/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## 3.0.1 (2020-05-31) 7 | 8 | - chore(deps): update ([a6af6a2](https://github.com/kripod/react-hooks/commit/a6af6a2)) 9 | 10 | # [3.0.0](https://github.com/kripod/react-hooks/compare/state-hooks@2.1.0...state-hooks@3.0.0) (2020-03-30) 11 | 12 | **Note:** Version bump only for package state-hooks 13 | 14 | # [2.1.0](https://github.com/kripod/react-hooks/compare/state-hooks@2.0.1...state-hooks@2.1.0) (2019-10-26) 15 | 16 | ### Features 17 | 18 | - add useChanging for change detection ([b5a95d1](https://github.com/kripod/react-hooks/commit/b5a95d19e0603fa8dc63be01a2a937af99b9bd32)), closes [#113](https://github.com/kripod/react-hooks/issues/113) 19 | 20 | ## [2.0.1](https://github.com/kripod/react-hooks/compare/state-hooks@2.0.0...state-hooks@2.0.1) (2019-10-16) 21 | 22 | **Note:** Version bump only for package state-hooks 23 | 24 | # 2.0.0 (2019-10-13) 25 | 26 | ### BREAKING CHANGES 27 | 28 | - **useTimeline:** decrease default `maxLength` for better performance ([536447f](https://github.com/kripod/react-hooks/commit/536447f82036919ec3f89c50fb3dab2d885736d3)) 29 | -------------------------------------------------------------------------------- /packages/state-hooks/README.md: -------------------------------------------------------------------------------- 1 | # state-hooks 2 | 3 | Essential set of [React Hooks] for convenient state management. 4 | 5 | [react hooks]: https://reactjs.org/docs/hooks-intro.html 6 | 7 | ## Key features 8 | 9 | Being part of the [@kripod/react-hooks] project, this package is: 10 | 11 | - 🌳 **Bundler-friendly** with tree shaking support 12 | - 📚 **Well-documented** and type-safe interfaces 13 | - ⚛️ **Zero-config** server-side rendering capability 14 | - 📦 **Self-contained**, free of runtime dependencies 15 | 16 | [@kripod/react-hooks]: https://github.com/kripod/react-hooks 17 | 18 | ## Usage 19 | 20 | After installing the package, import individual hooks as shown below: 21 | 22 | ```javascript 23 | import { usePrevious, useUndoable } from 'state-hooks'; 24 | ``` 25 | 26 | ## Reference 27 | 28 | 29 | 30 | #### Table of Contents 31 | 32 | - [useChanging](#usechanging) 33 | - [usePrevious](#useprevious) 34 | - [useTimeline](#usetimeline) 35 | - [useToggle](#usetoggle) 36 | - [useUndoable](#useundoable) 37 | 38 | ### useChanging 39 | 40 | Tracks whether a value has changed over a relatively given period of time. 41 | 42 | #### Parameters 43 | 44 | - `value` **T** Props, state or any other calculated value. 45 | - `groupingIntervalMs` **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** Time interval, in milliseconds, to group a batch of changes by. (optional, default `150`) 46 | 47 | #### Examples 48 | 49 | ```javascript 50 | function Component() { 51 | const scrollCoords = useWindowScrollCoords(); 52 | const isScrolling = useChanging(scrollCoords); 53 | // ... 54 | } 55 | ``` 56 | 57 | Returns **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** `true` if the value has changed at least once over the given interval, or `false` otherwise. 58 | 59 | ### usePrevious 60 | 61 | Tracks previous state of a value. 62 | 63 | #### Parameters 64 | 65 | - `value` **T** Props, state or any other calculated value. 66 | 67 | #### Examples 68 | 69 | ```javascript 70 | function Component() { 71 | const [count, setCount] = useState(0); 72 | const prevCount = usePrevious(count); 73 | // ... 74 | return `Now: ${count}, before: ${prevCount}`; 75 | } 76 | ``` 77 | 78 | Returns **(T | [undefined](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/undefined))** Value from the previous render of the enclosing component. 79 | 80 | ### useTimeline 81 | 82 | Records states of a value over time. 83 | 84 | #### Parameters 85 | 86 | - `value` **T** Props, state or any other calculated value. 87 | - `maxLength` **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** Maximum amount of states to store at once. Should be an integer greater than 1. (optional, default `MAX_SMALL_INTEGER`) 88 | 89 | #### Examples 90 | 91 | ```javascript 92 | function Component() { 93 | const [count, setCount] = useState(0); 94 | const counts = useTimeline(count); 95 | // ... 96 | return `Now: ${count}, history: ${counts}`; 97 | } 98 | ``` 99 | 100 | Returns **ReadonlyArray<T>** Results of state updates in chronological order. 101 | 102 | ### useToggle 103 | 104 | Wraps a state hook to add boolean toggle functionality. 105 | 106 | #### Parameters 107 | 108 | - `useStateResult` **\[[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean), React.Dispatch<React.SetStateAction<[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)>>]** Return value of a state hook. 109 | - `useStateResult.0` Current state. 110 | - `useStateResult.1` State updater function. 111 | 112 | #### Examples 113 | 114 | ```javascript 115 | function Component() { 116 | const [isPressed, setPressed, togglePressed] = useToggle( 117 | useState < boolean > false, 118 | ); 119 | // ... 120 | return ( 121 | 124 | ); 125 | } 126 | ``` 127 | 128 | Returns **\[[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean), React.Dispatch<React.SetStateAction<[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)>>, function (): void]** State hook result extended with a `toggle` function. 129 | 130 | ### useUndoable 131 | 132 | Wraps a state hook to add undo/redo functionality. 133 | 134 | #### Parameters 135 | 136 | - `useStateResult` **\[T, React.Dispatch<React.SetStateAction<T>>]** Return value of a state hook. 137 | - `useStateResult.0` Current state. 138 | - `useStateResult.1` State updater function. 139 | - `maxDeltas` **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** Maximum amount of state differences to store at once. Should be a positive integer. (optional, default `MAX_SMALL_INTEGER`) 140 | 141 | #### Examples 142 | 143 | ```javascript 144 | function Component() { 145 | const [value, setValue, { undo, redo, past, future }] = useUndoable( 146 | useState(''), 147 | ); 148 | // ... 149 | return ( 150 | <> 151 | 154 | setValue(event.target.value)} /> 155 | 158 | 159 | ); 160 | } 161 | ``` 162 | 163 | Returns **\[T, React.Dispatch<React.SetStateAction<T>>, {undo: function (): void, redo: function (): void, past: [Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<T>, future: [Array](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array)<T>, jump: function (delta: [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)): void}]** State hook result extended with an object containing `undo`, `redo`, `past`, `future` and `jump`. 164 | -------------------------------------------------------------------------------- /packages/state-hooks/documentation.yml: -------------------------------------------------------------------------------- 1 | toc: 2 | - useChanging 3 | - usePrevious 4 | - useTimeline 5 | - useToggle 6 | - useUndoable 7 | -------------------------------------------------------------------------------- /packages/state-hooks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "state-hooks", 3 | "version": "3.0.1", 4 | "description": "Essential set of React Hooks for convenient state management.", 5 | "keywords": [ 6 | "react", 7 | "hooks", 8 | "react-hooks", 9 | "essential", 10 | "state-management" 11 | ], 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/kripod/react-hooks.git", 15 | "directory": "packages/state-hooks" 16 | }, 17 | "license": "MIT", 18 | "author": "Kristóf Poduszló ", 19 | "scripts": { 20 | "build": "pika build", 21 | "doc": "documentation readme src --section Reference --config documentation.yml --markdown-toc-max-depth 2 --parse-extension ts && prettier --write README.md" 22 | }, 23 | "devDependencies": { 24 | "typescript": "^3.9.3" 25 | }, 26 | "peerDependencies": { 27 | "react": ">=16.8" 28 | }, 29 | "@pika/pack": { 30 | "pipeline": [ 31 | [ 32 | "@pika/plugin-ts-standard-pkg" 33 | ], 34 | [ 35 | "@pika/plugin-build-node" 36 | ], 37 | [ 38 | "@pika/plugin-build-web" 39 | ] 40 | ] 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /packages/state-hooks/src/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useChanging } from './useChanging'; 2 | export { default as usePrevious } from './usePrevious'; 3 | export { default as useTimeline } from './useTimeline'; 4 | export { default as useToggle } from './useToggle'; 5 | export { default as useUndoable } from './useUndoable'; 6 | -------------------------------------------------------------------------------- /packages/state-hooks/src/useChanging.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | 3 | import { useChanging } from '.'; 4 | 5 | jest.useFakeTimers(); 6 | 7 | test('detect changes of a value over time', () => { 8 | const { result, rerender } = renderHook(({ value }) => useChanging(value), { 9 | initialProps: { value: 0 }, 10 | }); 11 | expect(result.current).toBe(false); 12 | 13 | rerender({ value: 1 }); 14 | expect(result.current).toBe(true); 15 | 16 | act(() => { 17 | jest.advanceTimersByTime(100); 18 | }); 19 | expect(result.current).toBe(true); 20 | 21 | act(() => { 22 | jest.advanceTimersByTime(100); 23 | }); 24 | expect(result.current).toBe(false); 25 | 26 | rerender({ value: 2 }); 27 | expect(result.current).toBe(true); 28 | }); 29 | 30 | test('handle changing grouping interval', () => { 31 | const { result, rerender } = renderHook( 32 | ({ value, groupingIntervalMs }) => useChanging(value, groupingIntervalMs), 33 | { initialProps: { value: 0, groupingIntervalMs: 500 } }, 34 | ); 35 | 36 | rerender({ value: 1, groupingIntervalMs: 500 }); 37 | act(() => { 38 | jest.advanceTimersByTime(300); 39 | }); 40 | expect(result.current).toBe(true); 41 | 42 | rerender({ value: 1, groupingIntervalMs: 1000 }); 43 | act(() => { 44 | jest.advanceTimersByTime(700); 45 | }); 46 | expect(result.current).toBe(true); 47 | 48 | act(() => { 49 | jest.advanceTimersByTime(300); 50 | }); 51 | expect(result.current).toBe(false); 52 | }); 53 | -------------------------------------------------------------------------------- /packages/state-hooks/src/useChanging.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef, useState } from 'react'; 2 | 3 | /** 4 | * Tracks whether a value has changed over a relatively given period of time. 5 | * 6 | * @param value Props, state or any other calculated value. 7 | * @param {number} groupingIntervalMs Time interval, in milliseconds, to group a batch of changes by. 8 | * @returns `true` if the value has changed at least once over the given interval, or `false` otherwise. 9 | * 10 | * @example 11 | * function Component() { 12 | * const scrollCoords = useWindowScrollCoords(); 13 | * const isScrolling = useChanging(scrollCoords); 14 | * // ... 15 | * } 16 | */ 17 | export default function useChanging( 18 | value: T, 19 | groupingIntervalMs = 150, 20 | ): boolean { 21 | const [isChanging, setChanging] = useState(false); 22 | const prevGroupingIntervalMsRef = useRef(0); 23 | 24 | useEffect(() => { 25 | // Prevent initial state from being true 26 | if (groupingIntervalMs !== prevGroupingIntervalMsRef.current) { 27 | prevGroupingIntervalMsRef.current = groupingIntervalMs; 28 | } else { 29 | setChanging(true); 30 | } 31 | 32 | const timeoutID = setTimeout(() => setChanging(false), groupingIntervalMs); 33 | return (): void => { 34 | clearTimeout(timeoutID); 35 | }; 36 | }, [groupingIntervalMs, value]); 37 | 38 | return isChanging; 39 | } 40 | -------------------------------------------------------------------------------- /packages/state-hooks/src/usePrevious.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { useState } from 'react'; 3 | 4 | import { usePrevious } from '.'; 5 | 6 | test('get previous state', () => { 7 | const { result } = renderHook(() => { 8 | const [count, setCount] = useState(0); 9 | return { 10 | count, 11 | setCount, 12 | prevCount: usePrevious(count), 13 | }; 14 | }); 15 | expect(result.current.prevCount).toBe(undefined); 16 | 17 | act(() => { 18 | result.current.setCount(11); 19 | }); 20 | expect(result.current.prevCount).toBe(0); 21 | 22 | act(() => { 23 | result.current.setCount(22); 24 | }); 25 | expect(result.current.prevCount).toBe(11); 26 | }); 27 | -------------------------------------------------------------------------------- /packages/state-hooks/src/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | /** 4 | * Tracks previous state of a value. 5 | * 6 | * @param value Props, state or any other calculated value. 7 | * @returns Value from the previous render of the enclosing component. 8 | * 9 | * @example 10 | * function Component() { 11 | * const [count, setCount] = useState(0); 12 | * const prevCount = usePrevious(count); 13 | * // ... 14 | * return `Now: ${count}, before: ${prevCount}`; 15 | * } 16 | */ 17 | export default function usePrevious(value: T): T | undefined { 18 | // Source: https://reactjs.org/docs/hooks-faq.html#how-to-get-the-previous-props-or-state 19 | const ref = useRef(); 20 | useEffect(() => { 21 | ref.current = value; 22 | }); 23 | return ref.current; 24 | } 25 | -------------------------------------------------------------------------------- /packages/state-hooks/src/useTimeline.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { useState } from 'react'; 3 | 4 | import { usePrevious, useTimeline } from '.'; 5 | 6 | test('store previous states indefinitely', () => { 7 | const { result } = renderHook(() => { 8 | const [count, setCount] = useState(0); 9 | const counts = useTimeline(count); 10 | return { count, setCount, counts }; 11 | }); 12 | expect(result.current.counts).toEqual([0]); 13 | 14 | act(() => { 15 | result.current.setCount(11); 16 | }); 17 | expect(result.current.counts).toEqual([0, 11]); 18 | 19 | act(() => { 20 | result.current.setCount(22); 21 | }); 22 | expect(result.current.counts).toEqual([0, 11, 22]); 23 | }); 24 | 25 | test('store previous states with a space limit', () => { 26 | const { result } = renderHook(() => { 27 | const [count, setCount] = useState(0); 28 | const counts = useTimeline(count, 2); 29 | return { count, setCount, counts }; 30 | }); 31 | expect(result.current.counts).toEqual([0]); 32 | 33 | act(() => { 34 | result.current.setCount(11); 35 | }); 36 | expect(result.current.counts).toEqual([0, 11]); 37 | 38 | act(() => { 39 | result.current.setCount(22); 40 | }); 41 | expect(result.current.counts).toEqual([11, 22]); 42 | }); 43 | 44 | test('immutability of timeline values', () => { 45 | const { result } = renderHook(() => { 46 | const [count, setCount] = useState(0); 47 | const counts = useTimeline(count, 2); 48 | const prevCounts = usePrevious(counts); 49 | return { count, setCount, counts, prevCounts }; 50 | }); 51 | expect(result.current.prevCounts).toBe(undefined); 52 | 53 | act(() => { 54 | result.current.setCount(11); 55 | }); 56 | expect(result.current.counts).toEqual([0, 11]); 57 | expect(result.current.prevCounts).toEqual([0]); 58 | 59 | act(() => { 60 | result.current.setCount(22); 61 | }); 62 | expect(result.current.counts).toEqual([11, 22]); 63 | expect(result.current.prevCounts).toEqual([0, 11]); 64 | }); 65 | 66 | test('change timeline length limit', () => { 67 | const { result } = renderHook(() => { 68 | const [count, setCount] = useState(0); 69 | const [maxLength, setMaxLength] = useState(10); 70 | const counts = useTimeline(count, maxLength); 71 | return { count, setCount, setMaxLength, counts }; 72 | }); 73 | 74 | act(() => { 75 | result.current.setCount(11); 76 | }); 77 | expect(result.current.counts).toEqual([0, 11]); 78 | 79 | act(() => { 80 | result.current.setCount(22); 81 | }); 82 | expect(result.current.counts).toEqual([0, 11, 22]); 83 | 84 | act(() => { 85 | result.current.setMaxLength(2); 86 | }); 87 | expect(result.current.counts).toEqual([11, 22]); 88 | }); 89 | -------------------------------------------------------------------------------- /packages/state-hooks/src/useTimeline.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | import usePrevious from './usePrevious'; 4 | import { MAX_SMALL_INTEGER } from './utils'; 5 | 6 | /** 7 | * Records states of a value over time. 8 | * 9 | * @param value Props, state or any other calculated value. 10 | * @param maxLength Maximum amount of states to store at once. Should be an integer greater than 1. 11 | * @returns Results of state updates in chronological order. 12 | * 13 | * @example 14 | * function Component() { 15 | * const [count, setCount] = useState(0); 16 | * const counts = useTimeline(count); 17 | * // ... 18 | * return `Now: ${count}, history: ${counts}`; 19 | * } 20 | */ 21 | export default function useTimeline( 22 | value: T, 23 | maxLength: number = MAX_SMALL_INTEGER, 24 | ): ReadonlyArray { 25 | const valuesRef = useRef([]); 26 | const prevValue = usePrevious(value); 27 | 28 | if (!Object.is(value, prevValue)) { 29 | // Use immutable refs to behave like state variables 30 | valuesRef.current = [...valuesRef.current, value]; 31 | } 32 | 33 | if (valuesRef.current.length > maxLength) { 34 | valuesRef.current.splice(0, valuesRef.current.length - maxLength); 35 | } 36 | 37 | return valuesRef.current; 38 | } 39 | -------------------------------------------------------------------------------- /packages/state-hooks/src/useToggle.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { useState } from 'react'; 3 | 4 | import { useToggle } from '.'; 5 | 6 | test('negate toggle state', () => { 7 | const { result } = renderHook(() => useToggle(useState(false))); 8 | expect(result.current[0]).toBe(false); 9 | 10 | act(() => { 11 | result.current[2](); 12 | }); 13 | expect(result.current[0]).toBe(true); 14 | 15 | act(() => { 16 | result.current[2](); 17 | }); 18 | expect(result.current[0]).toBe(false); 19 | }); 20 | 21 | test('set toggle state', () => { 22 | const { result } = renderHook(() => useToggle(useState(true))); 23 | expect(result.current[0]).toBe(true); 24 | 25 | act(() => { 26 | result.current[1](true); 27 | }); 28 | expect(result.current[0]).toBe(true); 29 | 30 | act(() => { 31 | result.current[1](false); 32 | }); 33 | expect(result.current[0]).toBe(false); 34 | }); 35 | -------------------------------------------------------------------------------- /packages/state-hooks/src/useToggle.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | 3 | /* eslint-disable jsdoc/valid-types */ 4 | 5 | /** 6 | * Wraps a state hook to add boolean toggle functionality. 7 | * 8 | * @param useStateResult Return value of a state hook. 9 | * @param useStateResult.0 Current state. 10 | * @param useStateResult.1 State updater function. 11 | * @returns State hook result extended with a `toggle` function. 12 | * 13 | * @example 14 | * function Component() { 15 | * const [isPressed, setPressed, togglePressed] = useToggle( 16 | * useState(false), 17 | * ); 18 | * // ... 19 | * return ( 20 | * 23 | * ); 24 | * } 25 | */ 26 | export default function useToggle([value, setValue]: [ 27 | boolean, 28 | React.Dispatch>, 29 | ]): [boolean, React.Dispatch>, () => void] { 30 | const toggleValue = useCallback(() => { 31 | setValue((prevValue) => !prevValue); 32 | }, [setValue]); 33 | return [value, setValue, toggleValue]; 34 | } 35 | -------------------------------------------------------------------------------- /packages/state-hooks/src/useUndoable.test.ts: -------------------------------------------------------------------------------- 1 | import { act, renderHook } from '@testing-library/react-hooks'; 2 | import { useState } from 'react'; 3 | 4 | import { useUndoable } from '.'; 5 | 6 | test('basic undo/redo functionality', () => { 7 | const { result } = renderHook(() => useUndoable(useState(11))); 8 | expect(result.current[0]).toBe(11); 9 | 10 | const [, setValue, { undo, redo }] = result.current; 11 | 12 | act(() => { 13 | setValue(22); 14 | }); 15 | expect(result.current[0]).toBe(22); 16 | expect(result.current[2].past).toEqual([11]); 17 | expect(result.current[2].future).toEqual([]); 18 | 19 | act(() => { 20 | undo(); 21 | }); 22 | expect(result.current[0]).toBe(11); 23 | expect(result.current[2].past).toEqual([]); 24 | expect(result.current[2].future).toEqual([22]); 25 | 26 | act(() => { 27 | redo(); 28 | }); 29 | expect(result.current[0]).toBe(22); 30 | expect(result.current[2].past).toEqual([11]); 31 | expect(result.current[2].future).toEqual([]); 32 | }); 33 | 34 | test('jump between deltas', () => { 35 | const { result } = renderHook(() => useUndoable(useState(11))); 36 | 37 | const [, setValue, { jump }] = result.current; 38 | 39 | act(() => { 40 | setValue(22); 41 | }); 42 | act(() => { 43 | setValue(33); 44 | }); 45 | 46 | act(() => { 47 | jump(-2); 48 | }); 49 | expect(result.current[0]).toBe(11); 50 | expect(result.current[2].past).toEqual([]); 51 | expect(result.current[2].future).toEqual([22, 33]); 52 | 53 | act(() => { 54 | jump(+2); 55 | }); 56 | expect(result.current[0]).toBe(33); 57 | expect(result.current[2].past).toEqual([11, 22]); 58 | expect(result.current[2].future).toEqual([]); 59 | }); 60 | 61 | test('apply state updater function on undoable state', () => { 62 | const { result } = renderHook(() => useUndoable(useState(11))); 63 | 64 | const [, setValue] = result.current; 65 | 66 | act(() => { 67 | setValue((prevValue) => prevValue + 1); 68 | }); 69 | expect(result.current[0]).toBe(12); 70 | }); 71 | 72 | test('avoids overflow/underflow during undo/redo', () => { 73 | const { result } = renderHook(() => useUndoable(useState(11))); 74 | 75 | const [, setValue, { undo, redo }] = result.current; 76 | 77 | act(() => { 78 | setValue(22); 79 | }); 80 | 81 | act(() => { 82 | redo(); 83 | }); 84 | expect(result.current[0]).toBe(22); 85 | 86 | act(() => { 87 | undo(); 88 | }); 89 | expect(result.current[0]).toBe(11); 90 | 91 | act(() => { 92 | undo(); 93 | }); 94 | expect(result.current[0]).toBe(11); 95 | }); 96 | 97 | test('truncates redos on undoable state update', () => { 98 | const { result } = renderHook(() => useUndoable(useState(11))); 99 | 100 | const [, setValue, { undo, redo }] = result.current; 101 | 102 | act(() => { 103 | setValue(22); 104 | }); 105 | 106 | act(() => { 107 | undo(); 108 | }); 109 | expect(result.current[0]).toBe(11); 110 | 111 | act(() => { 112 | setValue(33); 113 | }); 114 | expect(result.current[0]).toBe(33); 115 | 116 | act(() => { 117 | redo(); 118 | }); 119 | expect(result.current[0]).toBe(33); 120 | 121 | act(() => { 122 | undo(); 123 | }); 124 | expect(result.current[0]).toBe(11); 125 | }); 126 | 127 | test('limits amount of deltas available', () => { 128 | const { result, rerender } = renderHook( 129 | ({ maxDeltas }) => useUndoable(useState(11), maxDeltas), 130 | { initialProps: { maxDeltas: 2 } }, 131 | ); 132 | 133 | const [, setValue, { undo }] = result.current; 134 | 135 | act(() => { 136 | setValue(22); 137 | }); 138 | act(() => { 139 | setValue(33); 140 | }); 141 | expect(result.current[2].past).toEqual([11, 22]); 142 | 143 | act(() => { 144 | setValue(44); 145 | }); 146 | expect(result.current[2].past).toEqual([22, 33]); 147 | 148 | rerender({ maxDeltas: 1 }); 149 | expect(result.current[2].past).toEqual([33]); 150 | 151 | rerender({ maxDeltas: 2 }); 152 | act(() => { 153 | setValue(11); 154 | }); 155 | expect(result.current[2].past).toEqual([33, 44]); 156 | 157 | act(() => { 158 | undo(); 159 | }); 160 | expect(result.current[2].past).toEqual([33]); 161 | expect(result.current[2].future).toEqual([11]); 162 | 163 | rerender({ maxDeltas: 1 }); 164 | expect(result.current[2].past).toEqual([33]); 165 | expect(result.current[2].future).toEqual([]); 166 | 167 | rerender({ maxDeltas: 0 }); 168 | expect(result.current[2].past).toEqual([]); 169 | expect(result.current[2].future).toEqual([]); 170 | }); 171 | -------------------------------------------------------------------------------- /packages/state-hooks/src/useUndoable.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from 'react'; 2 | 3 | import { MAX_SMALL_INTEGER } from './utils'; 4 | 5 | /** 6 | * Wraps a state hook to add undo/redo functionality. 7 | * 8 | * @param useStateResult Return value of a state hook. 9 | * @param useStateResult.0 Current state. 10 | * @param useStateResult.1 State updater function. 11 | * @param maxDeltas Maximum amount of state differences to store at once. Should be a positive integer. 12 | * @returns State hook result extended with an object containing `undo`, `redo`, `past`, `future` and `jump`. 13 | * 14 | * @example 15 | * function Component() { 16 | * const [value, setValue, { undo, redo, past, future }] = useUndoable( 17 | * useState(''), 18 | * ); 19 | * // ... 20 | * return ( 21 | * <> 22 | * 25 | * setValue(event.target.value)} /> 26 | * 29 | * 30 | * ); 31 | * } 32 | */ 33 | export default function useUndoable( 34 | [value, setValue]: [T, React.Dispatch>], 35 | maxDeltas: number = MAX_SMALL_INTEGER, 36 | ): [ 37 | T, 38 | React.Dispatch>, 39 | { 40 | undo: () => void; 41 | redo: () => void; 42 | past: T[]; 43 | future: T[]; 44 | jump: (delta: number) => void; 45 | }, 46 | ] { 47 | // Source: https://redux.js.org/recipes/implementing-undo-history 48 | const pastValuesRef = useRef([]); 49 | const futureValuesRef = useRef([]); 50 | 51 | const newSetValue = useCallback( 52 | (update: React.SetStateAction) => { 53 | setValue((prevValue) => { 54 | futureValuesRef.current = []; 55 | pastValuesRef.current = [...pastValuesRef.current, prevValue]; 56 | return typeof update === 'function' 57 | ? (update as (prevValue: T) => T)(prevValue) 58 | : update; 59 | }); 60 | }, 61 | [setValue], 62 | ); 63 | 64 | const jump = useCallback( 65 | (delta: number) => { 66 | if (delta < 0 && pastValuesRef.current.length >= -delta) { 67 | // Undo 68 | setValue((prevValue) => { 69 | const nextValueIndex = pastValuesRef.current.length + delta; 70 | const nextValue = pastValuesRef.current[nextValueIndex]; 71 | futureValuesRef.current = [ 72 | ...pastValuesRef.current.slice(nextValueIndex + 1), 73 | prevValue, 74 | ...futureValuesRef.current, 75 | ]; 76 | pastValuesRef.current = pastValuesRef.current.slice(0, delta); 77 | return nextValue; 78 | }); 79 | } else if (delta > 0 && futureValuesRef.current.length >= delta) { 80 | // Redo 81 | setValue((prevValue) => { 82 | const nextValue = futureValuesRef.current[delta - 1]; 83 | pastValuesRef.current = [ 84 | ...pastValuesRef.current, 85 | prevValue, 86 | ...futureValuesRef.current.slice(0, delta - 1), 87 | ]; 88 | futureValuesRef.current = futureValuesRef.current.slice(delta); 89 | return nextValue; 90 | }); 91 | } 92 | }, 93 | [setValue], 94 | ); 95 | 96 | const undo = useCallback(() => jump(-1), [jump]); 97 | const redo = useCallback(() => jump(+1), [jump]); 98 | 99 | const deltas = pastValuesRef.current.length + futureValuesRef.current.length; 100 | if (deltas > maxDeltas) { 101 | futureValuesRef.current.splice(maxDeltas - deltas, MAX_SMALL_INTEGER); 102 | pastValuesRef.current.splice(0, pastValuesRef.current.length - maxDeltas); 103 | } 104 | 105 | return [ 106 | value, 107 | newSetValue, 108 | { 109 | undo, 110 | redo, 111 | past: pastValuesRef.current, 112 | future: futureValuesRef.current, 113 | jump, 114 | }, 115 | ]; 116 | } 117 | -------------------------------------------------------------------------------- /packages/state-hooks/src/utils.ts: -------------------------------------------------------------------------------- 1 | // Source: https://v8.dev/blog/react-cliff#value-representation 2 | // eslint-disable-next-line import/prefer-default-export 3 | export const MAX_SMALL_INTEGER = 2 ** 30 - 1; 4 | -------------------------------------------------------------------------------- /packages/state-hooks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "exclude": ["**/*.test.*"] 4 | } 5 | -------------------------------------------------------------------------------- /packages/web-api-hooks/.eslintrc.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | rules: { 5 | 'import/no-extraneous-dependencies': [ 6 | 'error', 7 | { packageDir: [__dirname, path.join(__dirname, '../..')] }, 8 | ], 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /packages/web-api-hooks/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. 5 | 6 | ## 3.0.2 (2020-05-31) 7 | 8 | - improvement: media queries level 5 compliance ([671022a](https://github.com/kripod/react-hooks/commit/671022a)) 9 | - chore(deps): update ([a6af6a2](https://github.com/kripod/react-hooks/commit/a6af6a2)) 10 | 11 | ## [3.0.1](https://github.com/kripod/react-hooks/compare/web-api-hooks@3.0.0...web-api-hooks@3.0.1) (2020-03-30) 12 | 13 | ### Bug Fixes 14 | 15 | - bundle NetworkInformation properly ([2839251](https://github.com/kripod/react-hooks/commit/2839251e37ae6165bb3def0fea2d8f702cb86b86)) 16 | 17 | # [3.0.0](https://github.com/kripod/react-hooks/compare/web-api-hooks@2.2.2...web-api-hooks@3.0.0) (2020-03-30) 18 | 19 | ### Bug Fixes 20 | 21 | - importing NetworkInformation types ([54445f4](https://github.com/kripod/react-hooks/commit/54445f4a9da95854156de9338f7c39a0378f71c6)) 22 | 23 | ## [2.2.2](https://github.com/kripod/react-hooks/compare/web-api-hooks@2.2.1...web-api-hooks@2.2.2) (2019-10-26) 24 | 25 | **Note:** Version bump only for package web-api-hooks 26 | 27 | ## [2.2.1](https://github.com/kripod/react-hooks/compare/web-api-hooks@2.2.0...web-api-hooks@2.2.1) (2019-10-25) 28 | 29 | ### Bug Fixes 30 | 31 | - **useSize:** possible ReferenceError caused by accessing ResizeObserver ([b708153](https://github.com/kripod/react-hooks/commit/b708153b3347ecf1c08c71f841be6e432669c7ff)) 32 | 33 | # [2.2.0](https://github.com/kripod/react-hooks/compare/web-api-hooks@2.1.0...web-api-hooks@2.2.0) (2019-10-25) 34 | 35 | ### Bug Fixes 36 | 37 | - **useMedia:** handle query param changes immediately ([ccedf58](https://github.com/kripod/react-hooks/commit/ccedf58726b89ce962d80cb2ebbf0c2bbc218e3d)) 38 | - **useMedia:** server-side rendering behavior ([0b7de89](https://github.com/kripod/react-hooks/commit/0b7de8941f33efa2f8ea409b72f5f19f57643f67)), closes [#114](https://github.com/kripod/react-hooks/issues/114) 39 | - **useSize:** make observer work without override ([b5884d8](https://github.com/kripod/react-hooks/commit/b5884d8af0a69da7f5509c1103fe422a294ebc07)) 40 | 41 | ### Features 42 | 43 | - add useFocus sensor ([026f04c](https://github.com/kripod/react-hooks/commit/026f04cb00e2e8fd143b3c2c8ff6b44f8c6747e5)) 44 | - add useHover sensor ([888a5cb](https://github.com/kripod/react-hooks/commit/888a5cb4b27a1472284cc6eb2a2266b60e00c72a)) 45 | - add useSize for observing the dimensions of an element ([bb74f0b](https://github.com/kripod/react-hooks/commit/bb74f0bbd6404e7d654f62f2b887403ccaf16afa)) 46 | 47 | # [2.1.0](https://github.com/kripod/react-hooks/compare/web-api-hooks@2.0.1...web-api-hooks@2.1.0) (2019-10-24) 48 | 49 | ### Features 50 | 51 | - add usePreferredMotionIntensity ([eaed758](https://github.com/kripod/react-hooks/commit/eaed758a41a7a84e2c906782ff255ddb57fe4234)) 52 | 53 | ## [2.0.1](https://github.com/kripod/react-hooks/compare/web-api-hooks@2.0.0...web-api-hooks@2.0.1) (2019-10-16) 54 | 55 | ### Bug Fixes 56 | 57 | - **useMedia:** improve compatibility by using deprecated listener syntax ([8f76bab](https://github.com/kripod/react-hooks/commit/8f76bab19efce5f5ef377451d2df737973787186)), closes [#91](https://github.com/kripod/react-hooks/issues/91) 58 | 59 | # 2.0.0 (2019-10-13) 60 | 61 | ### Features 62 | 63 | - **useUndoable:** add `jump` method to apply multiple deltas at once ([29c6ed7](https://github.com/kripod/react-hooks/commit/29c6ed719111af75849de4448589669e937f7f73)), closes [#59](https://github.com/kripod/react-hooks/issues/59) 64 | 65 | ### BREAKING CHANGES 66 | 67 | - **useUndoable:** return state hook result extensions as an object ([0e352a9](https://github.com/kripod/react-hooks/commit/0e352a9aa598f864508afafbc2e293b9d32d9f33)) 68 | - **useToggle:** repurpose as a wrapper hook ([#78](https://github.com/kripod/react-hooks/pull/78)) ([269fb49](https://github.com/kripod/react-hooks/commit/269fb492ff7ea0804e0ebe07b7050aa0ebf2b377)), closes [#36](https://github.com/kripod/react-hooks/issues/36) 69 | -------------------------------------------------------------------------------- /packages/web-api-hooks/README.md: -------------------------------------------------------------------------------- 1 | # web-api-hooks 2 | 3 | Essential set of [React Hooks] for convenient [Web API] consumption. 4 | 5 | [react hooks]: https://reactjs.org/docs/hooks-intro.html 6 | [web api]: https://developer.mozilla.org/docs/Web/API 7 | 8 | ## Key features 9 | 10 | Being part of the [@kripod/react-hooks] project, this package is: 11 | 12 | - 🌳 **Bundler-friendly** with tree shaking support 13 | - 📚 **Well-documented** and type-safe interfaces 14 | - ⚛️ **Zero-config** server-side rendering capability 15 | - 📦 **Self-contained**, free of runtime dependencies 16 | 17 | [@kripod/react-hooks]: https://github.com/kripod/react-hooks 18 | 19 | ## Usage 20 | 21 | After installing the package, import individual hooks as shown below: 22 | 23 | ```javascript 24 | import { useGeolocation, useLocalStorage } from 'web-api-hooks'; 25 | ``` 26 | 27 | ## Sandbox 28 | 29 | [👉 Explore the API with working examples](https://codesandbox.io/s/focused-cookies-gt5rt) 30 | 31 | ## Reference 32 | 33 | 34 | 35 | #### Table of Contents 36 | 37 | - [Sensors](#sensors) 38 | - [useColorSchemePreference](#usecolorschemepreference) 39 | - [useDeviceMotion](#usedevicemotion) 40 | - [useDeviceOrientation](#usedeviceorientation) 41 | - [useDocumentReadiness](#usedocumentreadiness) 42 | - [useDocumentVisibility](#usedocumentvisibility) 43 | - [useFocus](#usefocus) 44 | - [useGeolocation](#usegeolocation) 45 | - [useHover](#usehover) 46 | - [useLanguagePreferences](#uselanguagepreferences) 47 | - [useMedia](#usemedia) 48 | - [useMotionPreference](#usemotionpreference) 49 | - [useMouseCoords](#usemousecoords) 50 | - [useNetworkAvailability](#usenetworkavailability) 51 | - [useNetworkInformation](#usenetworkinformation) 52 | - [useSize](#usesize) 53 | - [useViewportScale](#useviewportscale) 54 | - [useViewportScrollCoords](#useviewportscrollcoords) 55 | - [useViewportSize](#useviewportsize) 56 | - [useWindowScrollCoords](#usewindowscrollcoords) 57 | - [useWindowSize](#usewindowsize) 58 | - [Storage](#storage) 59 | - [useLocalStorage](#uselocalstorage) 60 | - [useSessionStorage](#usesessionstorage) 61 | - [Scheduling](#scheduling) 62 | - [useEventListener](#useeventlistener) 63 | - [useInterval](#useinterval) 64 | 65 | ### Sensors 66 | 67 | #### useColorSchemePreference 68 | 69 | Tracks color scheme preference of the user. 70 | 71 | ##### Examples 72 | 73 | ```javascript 74 | function Component() { 75 | const preferDarkMode = useColorSchemePreference() === 'dark'; 76 | // ... 77 | } 78 | ``` 79 | 80 | Returns **(`"light"` \| `"dark"`)** Preferred color scheme. 81 | 82 | #### useDeviceMotion 83 | 84 | Tracks acceleration and rotation rate of the device. 85 | 86 | ##### Examples 87 | 88 | ```javascript 89 | function Component() { 90 | const { acceleration, rotationRate, interval } = useDeviceMotion(); 91 | // ... 92 | } 93 | ``` 94 | 95 | Returns **EventArgs<[DeviceMotionEvent](https://developer.mozilla.org/docs/Web/API/DeviceMotionEvent)>** Own properties of the last corresponding event. 96 | 97 | #### useDeviceOrientation 98 | 99 | Tracks physical orientation of the device. 100 | 101 | ##### Examples 102 | 103 | ```javascript 104 | function Component() { 105 | const { alpha, beta, gamma } = useDeviceOrientation(); 106 | // ... 107 | } 108 | ``` 109 | 110 | Returns **EventArgs<[DeviceOrientationEvent](https://developer.mozilla.org/docs/Web/API/DeviceOrientationEvent)>** Own properties of the last corresponding event. 111 | 112 | #### useDocumentReadiness 113 | 114 | Tracks loading state of the page. 115 | 116 | ##### Examples 117 | 118 | ```javascript 119 | function Component() { 120 | const documentReadiness = useDocumentReadiness(); 121 | if (documentReadiness === 'interactive') { 122 | // You may interact with any element of the document from now 123 | } 124 | // ... 125 | } 126 | ``` 127 | 128 | Returns **[DocumentReadyState](https://developer.mozilla.org/docs/Web/API/Document/readyState)** Readiness of the [`document`](https://developer.mozilla.org/docs/Web/API/Document), which is `'loading'` by default. 129 | 130 | #### useDocumentVisibility 131 | 132 | Tracks visibility of the page. 133 | 134 | ##### Examples 135 | 136 | ```javascript 137 | function Component() { 138 | const documentVisibility = useDocumentVisibility(); 139 | if (documentVisibility === 'hidden') { 140 | // Reduce resource utilization to aid background page performance 141 | } 142 | // ... 143 | } 144 | ``` 145 | 146 | Returns **[VisibilityState](https://developer.mozilla.org/docs/Web/API/Document/visibilityState)** Visibility state of the [`document`](https://developer.mozilla.org/docs/Web/API/Document), which is `'visible'` by default. 147 | 148 | #### useFocus 149 | 150 | Tracks focus state of an element. 151 | 152 | ##### Examples 153 | 154 | ```javascript 155 | function Component() { 156 | const [isFocused, bindFocus] = useFocus(); 157 | // ... 158 | return ; 159 | } 160 | ``` 161 | 162 | Returns **\[[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean), Readonly<{onFocus: function (): void, onBlur: function (): void}>]** Whether the element has focus, and props to be spread over the element under observation. 163 | 164 | #### useGeolocation 165 | 166 | Tracks geolocation of the device. 167 | 168 | ##### Parameters 169 | 170 | - `options` **[PositionOptions](https://developer.mozilla.org/docs/Web/API/PositionOptions)?** Additional watching options. 171 | - `errorCallback` **function (error: [PositionError](https://developer.mozilla.org/docs/Web/API/PositionError)): void?** Method to execute in case of an error, e.g. when the user denies location sharing permissions. 172 | 173 | ##### Examples 174 | 175 | ```javascript 176 | function Component() { 177 | const geolocation = useGeolocation(); 178 | if (geolocation) { 179 | const { coords } = geolocation; 180 | } 181 | // ... 182 | } 183 | ``` 184 | 185 | Returns **([Position](https://developer.mozilla.org/docs/Web/API/Position) \| [undefined](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/undefined))** Locational data, or `undefined` when unavailable. 186 | 187 | #### useHover 188 | 189 | Tracks hover state of an element. 190 | 191 | ##### Parameters 192 | 193 | - `disallowTouch` **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** Determines whether touch gestures should be ignored. (optional, default `false`) 194 | 195 | ##### Examples 196 | 197 | ```javascript 198 | function Component() { 199 | const [isHovered, bindHover] = useHover(); 200 | // ... 201 | return ; 202 | } 203 | ``` 204 | 205 | Returns **\[[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean), Readonly<{onMouseEnter: function (): void, onMouseLeave: function (): void, onTouchStart: function (): void, onTouchEnd: function (): void}>]** Whether the element is hovered, and props to be spread over the element under observation. 206 | 207 | #### useLanguagePreferences 208 | 209 | Tracks language preferences of the user. 210 | 211 | ##### Examples 212 | 213 | ```javascript 214 | function Component() { 215 | const preferredLanguages = useLanguagePreferences(); 216 | // ... 217 | } 218 | ``` 219 | 220 | Returns **ReadonlyArray<[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)>** An array of [BCP 47](https://tools.ietf.org/html/bcp47) language tags, ordered by preference with the most preferred language first. 221 | 222 | #### useMedia 223 | 224 | Tracks match state of a media query. 225 | 226 | ##### Parameters 227 | 228 | - `query` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Media query to parse. 229 | 230 | ##### Examples 231 | 232 | ```javascript 233 | function Component() { 234 | const isWidescreen = useMedia('(min-aspect-ratio: 16/9)'); 235 | // ... 236 | } 237 | ``` 238 | 239 | Returns **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** `true` if the associated media query list matches the state of the [`document`](https://developer.mozilla.org/docs/Web/API/Document), or `false` otherwise. 240 | 241 | #### useMotionPreference 242 | 243 | Tracks motion intensity preference of the user. 244 | 245 | ##### Examples 246 | 247 | ```javascript 248 | function Component() { 249 | const preferReducedMotion = useMotionPreference() === 'reduce'; 250 | // ... 251 | } 252 | ``` 253 | 254 | Returns **(`"no-preference"` \| `"reduce"`)** Preferred motion intensity. 255 | 256 | #### useMouseCoords 257 | 258 | Tracks mouse position. 259 | 260 | ##### Examples 261 | 262 | ```javascript 263 | function Component() { 264 | const [mouseX, mouseY] = useMouseCoords(); 265 | // ... 266 | } 267 | ``` 268 | 269 | Returns **Readonly<\[[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number), [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)]>** Coordinates `[x, y]`, falling back to `[0, 0]` when unavailable. 270 | 271 | #### useNetworkAvailability 272 | 273 | Tracks information about the network's availability. 274 | 275 | ⚠️ _This attribute is inherently unreliable. A computer can be connected to a network without having internet access._ 276 | 277 | ##### Examples 278 | 279 | ```javascript 280 | function Component() { 281 | const isOnline = useNetworkAvailability(); 282 | // ... 283 | } 284 | ``` 285 | 286 | Returns **[boolean](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Boolean)** `false` if the user agent is definitely offline, or `true` if it might be online. 287 | 288 | #### useNetworkInformation 289 | 290 | Tracks information about the device's network connection. 291 | 292 | ⚗️ _The underlying technology is experimental. Please be aware about browser compatibility before using this in production._ 293 | 294 | ##### Examples 295 | 296 | ```javascript 297 | function Component() { 298 | const networkInformation = useNetworkInformation(); 299 | if (networkInformation) { 300 | const { effectiveType, downlink, rtt, saveData } = networkInformation; 301 | } 302 | // ... 303 | } 304 | ``` 305 | 306 | Returns **([NetworkInformation](https://developer.mozilla.org/docs/Web/API/NetworkInformation) \| [undefined](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/undefined))** Connection data, or `undefined` when unavailable. 307 | 308 | #### useSize 309 | 310 | Tracks size of an element. 311 | 312 | ⚗️ _The underlying technology is experimental. Please be aware about browser compatibility before using this in production._ 313 | 314 | ##### Parameters 315 | 316 | - `ref` **React.RefObject<[HTMLElement](https://developer.mozilla.org/docs/Web/HTML/Element)>** Attribute attached to the element under observation. 317 | - `ResizeObserverOverride` **TypeOf<ResizeObserver>** Replacement for `window.ResizeObserver`, e.g. [a polyfill](https://github.com/juggle/resize-observer). 318 | 319 | ##### Examples 320 | 321 | ```javascript 322 | function Component() { 323 | const ref = useRef < HTMLElement > null; 324 | const [width, height] = useSize(ref); 325 | // ... 326 | return ; 327 | } 328 | ``` 329 | 330 | Returns **Readonly<\[[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number), [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)]>** Dimensions `[width, height]`, falling back to `[0, 0]` when unavailable. 331 | 332 | #### useViewportScale 333 | 334 | Tracks visual viewport scale. 335 | 336 | ⚗️ _The underlying technology is experimental. Please be aware about browser compatibility before using this in production._ 337 | 338 | ##### Examples 339 | 340 | ```javascript 341 | function Component() { 342 | const viewportScale = useViewportScale(); 343 | // ... 344 | } 345 | ``` 346 | 347 | Returns **[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)** Pinch-zoom scaling factor, falling back to `0` when unavailable. 348 | 349 | #### useViewportScrollCoords 350 | 351 | Tracks visual viewport scroll position. 352 | 353 | ⚗️ _The underlying technology is experimental. Please be aware about browser compatibility before using this in production._ 354 | 355 | ##### Examples 356 | 357 | ```javascript 358 | function Component() { 359 | const [viewportScrollX, viewportScrollY] = useViewportScrollCoords(); 360 | // ... 361 | } 362 | ``` 363 | 364 | Returns **Readonly<\[[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number), [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)]>** Coordinates `[x, y]`, falling back to `[0, 0]` when unavailable. 365 | 366 | #### useViewportSize 367 | 368 | Tracks visual viewport size. 369 | 370 | ⚗️ _The underlying technology is experimental. Please be aware about browser compatibility before using this in production._ 371 | 372 | ##### Examples 373 | 374 | ```javascript 375 | function Component() { 376 | const [viewportWidth, viewportHeight] = useViewportSize(); 377 | // ... 378 | } 379 | ``` 380 | 381 | Returns **Readonly<\[[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number), [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)]>** Dimensions `[width, height]`, falling back to `[0, 0]` when unavailable. 382 | 383 | #### useWindowScrollCoords 384 | 385 | Tracks window scroll position. 386 | 387 | ##### Examples 388 | 389 | ```javascript 390 | function Component() { 391 | const [windowScrollX, windowScrollY] = useWindowScrollCoords(); 392 | // ... 393 | } 394 | ``` 395 | 396 | Returns **Readonly<\[[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number), [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)]>** Coordinates `[x, y]`, falling back to `[0, 0]` when unavailable. 397 | 398 | #### useWindowSize 399 | 400 | Tracks window size. 401 | 402 | ##### Examples 403 | 404 | ```javascript 405 | function Component() { 406 | const [windowWidth, windowHeight] = useWindowSize(); 407 | // ... 408 | } 409 | ``` 410 | 411 | Returns **Readonly<\[[number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number), [number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number)]>** Dimensions `[width, height]`, falling back to `[0, 0]` when unavailable. 412 | 413 | ### Storage 414 | 415 | #### useLocalStorage 416 | 417 | - **See: [`useState` hook](https://reactjs.org/docs/hooks-reference.html#usestate), which exposes a similar interface** 418 | 419 | Stores a key/value pair statefully in [`localStorage`](https://developer.mozilla.org/docs/Web/API/Window/localStorage). 420 | 421 | ##### Parameters 422 | 423 | - `key` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Identifier to associate the stored value with. 424 | - `initialValue` **(T | function (): T | null)** Value used when no item exists with the given key. Lazy initialization is available by using a function which returns the desired value. (optional, default `null`) 425 | - `errorCallback` **function (error: (DOMException | [TypeError](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/TypeError))): void?** Method to execute in case of an error, e.g. when the storage quota has been exceeded or trying to store a circular data structure. 426 | 427 | ##### Examples 428 | 429 | ```javascript 430 | function Component() { 431 | const [visitCount, setVisitCount] = 432 | useLocalStorage < number > ('visitCount', 0); 433 | useEffect(() => { 434 | setVisitCount((count) => count + 1); 435 | }, []); 436 | // ... 437 | } 438 | ``` 439 | 440 | Returns **\[T, React.Dispatch<React.SetStateAction<T>>]** A statefully stored value, and a function to update it. 441 | 442 | #### useSessionStorage 443 | 444 | - **See: [`useState` hook](https://reactjs.org/docs/hooks-reference.html#usestate), which exposes a similar interface** 445 | 446 | Stores a key/value pair statefully in [`sessionStorage`](https://developer.mozilla.org/docs/Web/API/Window/sessionStorage). 447 | 448 | ##### Parameters 449 | 450 | - `key` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Identifier to associate the stored value with. 451 | - `initialValue` **(T | function (): T | null)** Value used when no item exists with the given key. Lazy initialization is available by using a function which returns the desired value. (optional, default `null`) 452 | - `errorCallback` **function (error: (DOMException | [TypeError](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/TypeError))): void?** Method to execute in case of an error, e.g. when the storage quota has been exceeded or trying to store a circular data structure. 453 | 454 | ##### Examples 455 | 456 | ```javascript 457 | function Component() { 458 | const [name, setName] = useSessionStorage < string > ('name', 'Anonymous'); 459 | // ... 460 | } 461 | ``` 462 | 463 | Returns **\[T, React.Dispatch<React.SetStateAction<T>>]** A statefully stored value, and a function to update it. 464 | 465 | ### Scheduling 466 | 467 | #### useEventListener 468 | 469 | - **See: [Event reference on MDN](https://developer.mozilla.org/en-US/docs/Web/Events)** 470 | 471 | Listens to an event while the enclosing component is mounted. 472 | 473 | ##### Parameters 474 | 475 | - `target` **[EventTarget](https://developer.mozilla.org/docs/Web/API/EventTarget)** Target to listen on, possibly a DOM element or a remote service connector. 476 | - `type` **[string](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String)** Name of event (case-sensitive). 477 | - `callback` **[EventListener](https://developer.mozilla.org/docs/Web/API/EventListener)** Method to execute whenever the event fires. 478 | - `options` **AddEventListenerOptions?** Additional listener characteristics. 479 | 480 | ##### Examples 481 | 482 | ```javascript 483 | function Component() { 484 | useEventListener(window, 'error', () => { 485 | console.log('A resource failed to load.'); 486 | }); 487 | // ... 488 | } 489 | ``` 490 | 491 | Returns **void** 492 | 493 | #### useInterval 494 | 495 | Repeatedly calls a function with a fixed time delay between each call. 496 | 497 | 📝 _Timings may be inherently inaccurate, due to the implementation of [`setInterval`](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/setInterval) under the hood._ 498 | 499 | ##### Parameters 500 | 501 | - `callback` **function (): void** Method to execute periodically. 502 | - `delayMs` **([number](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Number) | null)** Time, in milliseconds, to wait between executions of the specified function. Set to `null` for pausing. 503 | 504 | ##### Examples 505 | 506 | ```javascript 507 | function Component() { 508 | useInterval(() => { 509 | // Custom logic to execute each second 510 | }, 1000); 511 | // ... 512 | } 513 | ``` 514 | 515 | Returns **void** 516 | 517 | ## Performance tips 518 | 519 | - Avoid layout thrashing by [debouncing or throttling](https://css-tricks.com/debouncing-throttling-explained-examples/) high frequency events, e.g. scrolling or mouse movements 520 | - Move non-primitive hook parameters to an outer scope or memoize them with [`useMemo`](https://reactjs.org/docs/hooks-reference.html#usememo), e.g.: 521 | 522 | ```tsx 523 | const geolocationOptions = { enableHighAccuracy: true }; 524 | function Component() { 525 | const geolocation = useGeolocation(geolocationOptions); 526 | // ... 527 | } 528 | ``` 529 | -------------------------------------------------------------------------------- /packages/web-api-hooks/documentation.yml: -------------------------------------------------------------------------------- 1 | toc: 2 | - name: Sensors 3 | children: 4 | - useColorSchemePreference 5 | - useDeviceMotion 6 | - useDeviceOrientation 7 | - useDocumentReadiness 8 | - useDocumentVisibility 9 | - useFocus 10 | - useGeolocation 11 | - useHover 12 | - useLanguagePreferences 13 | - useMedia 14 | - useMotionPreference 15 | - useMouseCoords 16 | - useNetworkAvailability 17 | - useNetworkInformation 18 | - useSize 19 | - useViewportScale 20 | - useViewportScrollCoords 21 | - useViewportSize 22 | - useWindowScrollCoords 23 | - useWindowSize 24 | - name: Storage 25 | children: 26 | - useLocalStorage 27 | - useSessionStorage 28 | - name: Scheduling 29 | children: 30 | - useEventListener 31 | - useInterval 32 | -------------------------------------------------------------------------------- /packages/web-api-hooks/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web-api-hooks", 3 | "version": "3.0.2", 4 | "description": "Essential set of React Hooks for convenient Web API consumption.", 5 | "keywords": [ 6 | "react", 7 | "hooks", 8 | "react-hooks", 9 | "essential" 10 | ], 11 | "repository": { 12 | "type": "git", 13 | "url": "https://github.com/kripod/react-hooks.git", 14 | "directory": "packages/web-api-hooks" 15 | }, 16 | "license": "MIT", 17 | "author": "Kristóf Poduszló ", 18 | "scripts": { 19 | "build": "pika build", 20 | "doc": "documentation readme src --section Reference --config documentation.yml --markdown-toc-max-depth 3 --parse-extension ts && prettier --write README.md" 21 | }, 22 | "dependencies": { 23 | "@types/resize-observer-browser": "^0.1.2" 24 | }, 25 | "devDependencies": { 26 | "typescript": "^3.9.3" 27 | }, 28 | "peerDependencies": { 29 | "react": ">=16.8" 30 | }, 31 | "@pika/pack": { 32 | "pipeline": [ 33 | [ 34 | "@pika/plugin-ts-standard-pkg" 35 | ], 36 | [ 37 | "@pika/plugin-build-node" 38 | ], 39 | [ 40 | "@pika/plugin-build-web" 41 | ] 42 | ] 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/experimental-types/NetworkInformation.bundled.ts: -------------------------------------------------------------------------------- 1 | // Source: https://github.com/lacolaco/network-information-types/blob/master/index.d.ts 2 | 3 | // W3C Spec Draft http://wicg.github.io/netinfo/ 4 | // Edition: Draft Community Group Report 20 February 2019 5 | 6 | // http://wicg.github.io/netinfo/#connection-types 7 | type ConnectionType = 8 | | 'bluetooth' 9 | | 'cellular' 10 | | 'ethernet' 11 | | 'mixed' 12 | | 'none' 13 | | 'other' 14 | | 'unknown' 15 | | 'wifi' 16 | | 'wimax'; 17 | 18 | // http://wicg.github.io/netinfo/#effectiveconnectiontype-enum 19 | type EffectiveConnectionType = '2g' | '3g' | '4g' | 'slow-2g'; 20 | 21 | // http://wicg.github.io/netinfo/#dom-megabit 22 | type Megabit = number; 23 | // http://wicg.github.io/netinfo/#dom-millisecond 24 | type Millisecond = number; 25 | 26 | // http://wicg.github.io/netinfo/#networkinformation-interface 27 | export interface NetworkInformation extends EventTarget { 28 | // http://wicg.github.io/netinfo/#type-attribute 29 | readonly type?: ConnectionType; 30 | // http://wicg.github.io/netinfo/#effectivetype-attribute 31 | readonly effectiveType?: EffectiveConnectionType; 32 | // http://wicg.github.io/netinfo/#downlinkmax-attribute 33 | readonly downlinkMax?: Megabit; 34 | // http://wicg.github.io/netinfo/#downlink-attribute 35 | readonly downlink?: Megabit; 36 | // http://wicg.github.io/netinfo/#rtt-attribute 37 | readonly rtt?: Millisecond; 38 | // http://wicg.github.io/netinfo/#savedata-attribute 39 | readonly saveData?: boolean; 40 | // http://wicg.github.io/netinfo/#handling-changes-to-the-underlying-connection 41 | onchange?: EventListener; 42 | } 43 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/experimental-types/NetworkInformation.d.ts: -------------------------------------------------------------------------------- 1 | // Source: https://github.com/lacolaco/network-information-types/blob/master/index.d.ts 2 | 3 | // W3C Spec Draft http://wicg.github.io/netinfo/ 4 | // Edition: Draft Community Group Report 20 February 2019 5 | 6 | // http://wicg.github.io/netinfo/#connection-types 7 | type ConnectionType = 8 | | 'bluetooth' 9 | | 'cellular' 10 | | 'ethernet' 11 | | 'mixed' 12 | | 'none' 13 | | 'other' 14 | | 'unknown' 15 | | 'wifi' 16 | | 'wimax'; 17 | 18 | // http://wicg.github.io/netinfo/#effectiveconnectiontype-enum 19 | type EffectiveConnectionType = '2g' | '3g' | '4g' | 'slow-2g'; 20 | 21 | // http://wicg.github.io/netinfo/#dom-megabit 22 | type Megabit = number; 23 | // http://wicg.github.io/netinfo/#dom-millisecond 24 | type Millisecond = number; 25 | 26 | // http://wicg.github.io/netinfo/#networkinformation-interface 27 | interface NetworkInformation extends EventTarget { 28 | // http://wicg.github.io/netinfo/#type-attribute 29 | readonly type?: ConnectionType; 30 | // http://wicg.github.io/netinfo/#effectivetype-attribute 31 | readonly effectiveType?: EffectiveConnectionType; 32 | // http://wicg.github.io/netinfo/#downlinkmax-attribute 33 | readonly downlinkMax?: Megabit; 34 | // http://wicg.github.io/netinfo/#downlink-attribute 35 | readonly downlink?: Megabit; 36 | // http://wicg.github.io/netinfo/#rtt-attribute 37 | readonly rtt?: Millisecond; 38 | // http://wicg.github.io/netinfo/#savedata-attribute 39 | readonly saveData?: boolean; 40 | // http://wicg.github.io/netinfo/#handling-changes-to-the-underlying-connection 41 | onchange?: EventListener; 42 | } 43 | 44 | // http://wicg.github.io/netinfo/#navigatornetworkinformation-interface 45 | /* eslint-disable @typescript-eslint/no-empty-interface */ 46 | declare interface Navigator extends NavigatorNetworkInformation {} 47 | declare interface WorkerNavigator extends NavigatorNetworkInformation {} 48 | /* eslint-enable @typescript-eslint/no-empty-interface */ 49 | 50 | // http://wicg.github.io/netinfo/#navigatornetworkinformation-interface 51 | declare interface NavigatorNetworkInformation { 52 | readonly connection?: NetworkInformation; 53 | } 54 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/experimental-types/VisualViewport.d.ts: -------------------------------------------------------------------------------- 1 | export default interface VisualViewport extends EventTarget { 2 | readonly offsetLeft: number; 3 | readonly offsetTop: number; 4 | 5 | readonly pageLeft: number; 6 | readonly pageTop: number; 7 | 8 | readonly width: number; 9 | readonly height: number; 10 | 11 | readonly scale: number; 12 | 13 | onresize: EventListener; 14 | onscroll: EventListener; 15 | } 16 | 17 | declare global { 18 | interface Window { 19 | readonly visualViewport: VisualViewport; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/index.ts: -------------------------------------------------------------------------------- 1 | export type { 2 | EventArgs, 3 | EventMap, 4 | JSONArray, 5 | JSONObject, 6 | JSONValue, 7 | } from './types'; 8 | export { default as useColorSchemePreference } from './useColorSchemePreference'; 9 | export { default as useDeviceMotion } from './useDeviceMotion'; 10 | export { default as useDeviceOrientation } from './useDeviceOrientation'; 11 | export { default as useDocumentReadiness } from './useDocumentReadiness'; 12 | export { default as useDocumentVisibility } from './useDocumentVisibility'; 13 | export { default as useEventListener } from './useEventListener'; 14 | export { default as useFocus } from './useFocus'; 15 | export { default as useGeolocation } from './useGeolocation'; 16 | export { default as useHover } from './useHover'; 17 | export { default as useInterval } from './useInterval'; 18 | export { default as useLanguagePreferences } from './useLanguagePreferences'; 19 | export { default as useLocalStorage } from './useLocalStorage'; 20 | export { default as useMedia } from './useMedia'; 21 | export { default as useMotionPreference } from './useMotionPreference'; 22 | export { default as useMouseCoords } from './useMouseCoords'; 23 | export { default as useNetworkAvailability } from './useNetworkAvailability'; 24 | export { default as useNetworkInformation } from './useNetworkInformation'; 25 | export { default as useSessionStorage } from './useSessionStorage'; 26 | export { default as useSize } from './useSize'; 27 | export { default as useViewportScale } from './useViewportScale'; 28 | export { default as useViewportScrollCoords } from './useViewportScrollCoords'; 29 | export { default as useViewportSize } from './useViewportSize'; 30 | export { default as useWindowScrollCoords } from './useWindowScrollCoords'; 31 | export { default as useWindowSize } from './useWindowSize'; 32 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/ssr.test.tsx: -------------------------------------------------------------------------------- 1 | /** @jest-environment node */ 2 | 3 | import React, { useRef } from 'react'; 4 | import { renderToString } from 'react-dom/server'; 5 | 6 | import * as hooks from '.'; 7 | 8 | interface HookProps { 9 | callback: () => T; 10 | } 11 | 12 | function Hook({ callback }: HookProps): JSX.Element { 13 | return <>{JSON.stringify(callback())}; 14 | } 15 | 16 | function renderHookToString(callback: () => unknown): string { 17 | return renderToString(); 18 | } 19 | 20 | test.each( 21 | Object.entries({ 22 | // Provide dummy parameters for hooks which need them 23 | ...hooks, 24 | useEventListener: () => 25 | hooks.useEventListener( 26 | (undefined as unknown) as EventTarget, 27 | 'foo', 28 | () => {}, 29 | ), 30 | useGeolocation: () => hooks.useGeolocation(), 31 | useInterval: () => hooks.useInterval(() => {}, 0), 32 | useLocalStorage: () => hooks.useLocalStorage('foo'), 33 | useMedia: () => hooks.useMedia('(min-width: 600px)'), 34 | useSize: () => hooks.useSize(useRef(null)), 35 | useSessionStorage: () => hooks.useSessionStorage('foo'), 36 | }), 37 | )('%s supports SSR', (_name, callback) => { 38 | renderHookToString(callback); 39 | }); 40 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/types.ts: -------------------------------------------------------------------------------- 1 | export type EventArgs = Omit; 2 | export type EventMap = T extends Window 3 | ? WindowEventMap 4 | : T extends Document 5 | ? DocumentEventMap 6 | : { [key: string]: Event }; 7 | 8 | export type JSONValue = 9 | | string 10 | | number 11 | | boolean 12 | | JSONArray 13 | | JSONObject 14 | | null; 15 | 16 | // eslint-disable-next-line @typescript-eslint/no-empty-interface 17 | export interface JSONArray extends Array {} 18 | 19 | export interface JSONObject { 20 | [key: string]: JSONValue; 21 | } 22 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useColorSchemePreference.ts: -------------------------------------------------------------------------------- 1 | import useMedia from './useMedia'; 2 | 3 | /** 4 | * Tracks color scheme preference of the user. 5 | * 6 | * @returns Preferred color scheme. 7 | * 8 | * @example 9 | * function Component() { 10 | * const preferDarkMode = useColorSchemePreference() === 'dark'; 11 | * // ... 12 | * } 13 | */ 14 | export default function useColorSchemePreference(): 'light' | 'dark' { 15 | const isDark = useMedia('(prefers-color-scheme: dark)'); 16 | 17 | if (isDark) return 'dark'; 18 | return 'light'; 19 | } 20 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useDeviceMotion.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/react'; 2 | import { act, renderHook } from '@testing-library/react-hooks'; 3 | 4 | import { useDeviceMotion } from '.'; 5 | 6 | // TODO: Remove this polyfill 7 | class DeviceMotionEvent extends Event { 8 | readonly acceleration: DeviceMotionEventAcceleration | null; 9 | 10 | readonly accelerationIncludingGravity: DeviceMotionEventAcceleration | null; 11 | 12 | readonly rotationRate: DeviceMotionEventRotationRate | null; 13 | 14 | readonly interval: number; 15 | 16 | constructor(type: string, eventInitDict: DeviceMotionEventInit = {}) { 17 | super(type, eventInitDict); 18 | 19 | this.acceleration = { 20 | x: null, 21 | y: null, 22 | z: null, 23 | ...eventInitDict.acceleration, 24 | }; 25 | 26 | this.accelerationIncludingGravity = { 27 | x: null, 28 | y: null, 29 | z: null, 30 | ...eventInitDict.accelerationIncludingGravity, 31 | }; 32 | 33 | this.rotationRate = { 34 | alpha: null, 35 | beta: null, 36 | gamma: null, 37 | ...eventInitDict.rotationRate, 38 | }; 39 | 40 | this.interval = eventInitDict.interval || 0; 41 | } 42 | } 43 | 44 | test('device lying flat on a horizontal surface with the screen upmost', () => { 45 | const { result } = renderHook(() => useDeviceMotion()); 46 | expect(result.current.acceleration).toBe(null); 47 | 48 | const eventArgs = { 49 | acceleration: { x: 0, y: 0, z: 0 }, 50 | accelerationIncludingGravity: { x: 0, y: 0, z: 9.81 }, 51 | rotationRate: { alpha: 0, beta: 0, gamma: 0 }, 52 | interval: 16, 53 | }; 54 | act(() => { 55 | fireEvent(window, new DeviceMotionEvent('devicemotion', eventArgs)); 56 | }); 57 | expect(result.current).toMatchObject(eventArgs); 58 | }); 59 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useDeviceMotion.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { EventArgs } from './types'; 4 | import { managedEventListener } from './utils'; 5 | 6 | // Source: https://w3c.github.io/deviceorientation/#dictdef-devicemotioneventinit 7 | const initialState: EventArgs = { 8 | acceleration: null, 9 | accelerationIncludingGravity: null, 10 | rotationRate: null, 11 | interval: 0, 12 | }; 13 | 14 | /** 15 | * Tracks acceleration and rotation rate of the device. 16 | * 17 | * @returns Own properties of the last corresponding event. 18 | * 19 | * @example 20 | * function Component() { 21 | * const { acceleration, rotationRate, interval } = useDeviceMotion(); 22 | * // ... 23 | * } 24 | */ 25 | export default function useDeviceMotion(): EventArgs { 26 | const [motion, setMotion] = useState(initialState); 27 | 28 | // TODO: Request permission if necessary, see https://github.com/w3c/deviceorientation/issues/57 29 | 30 | useEffect( 31 | () => 32 | managedEventListener(window, 'devicemotion', (event) => { 33 | setMotion(event); 34 | }), 35 | [], 36 | ); 37 | 38 | return motion; 39 | } 40 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useDeviceOrientation.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/react'; 2 | import { act, renderHook } from '@testing-library/react-hooks'; 3 | 4 | import { useDeviceOrientation } from '.'; 5 | 6 | // TODO: Remove this polyfill 7 | class DeviceOrientationEvent extends Event { 8 | readonly alpha: number | null; 9 | 10 | readonly beta: number | null; 11 | 12 | readonly gamma: number | null; 13 | 14 | readonly absolute: boolean; 15 | 16 | constructor(type: string, eventInitDict: DeviceOrientationEventInit = {}) { 17 | super(type, eventInitDict); 18 | 19 | this.alpha = eventInitDict.alpha != null ? eventInitDict.alpha : null; 20 | this.beta = eventInitDict.beta != null ? eventInitDict.beta : null; 21 | this.gamma = eventInitDict.gamma != null ? eventInitDict.gamma : null; 22 | this.absolute = eventInitDict.absolute || false; 23 | } 24 | } 25 | 26 | test('device lying flat on a horizontal surface with the top of the screen pointing west', () => { 27 | const { result } = renderHook(() => useDeviceOrientation()); 28 | expect(result.current.alpha).toBe(null); 29 | 30 | const eventArgs = { alpha: 90, beta: 0, gamma: 0 }; 31 | act(() => { 32 | fireEvent( 33 | window, 34 | new DeviceOrientationEvent('deviceorientation', eventArgs), 35 | ); 36 | }); 37 | expect(result.current).toMatchObject(eventArgs); 38 | }); 39 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useDeviceOrientation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { EventArgs } from './types'; 4 | import { managedEventListener } from './utils'; 5 | 6 | // Source: https://w3c.github.io/deviceorientation/#dictdef-deviceorientationeventinit 7 | const initialState: EventArgs = { 8 | alpha: null, 9 | beta: null, 10 | gamma: null, 11 | absolute: false, 12 | }; 13 | 14 | /** 15 | * Tracks physical orientation of the device. 16 | * 17 | * @returns Own properties of the last corresponding event. 18 | * 19 | * @example 20 | * function Component() { 21 | * const { alpha, beta, gamma } = useDeviceOrientation(); 22 | * // ... 23 | * } 24 | */ 25 | export default function useDeviceOrientation(): EventArgs< 26 | DeviceOrientationEvent 27 | > { 28 | const [orientation, setOrientation] = useState(initialState); 29 | 30 | useEffect( 31 | () => 32 | managedEventListener(window, 'deviceorientation', (event) => { 33 | setOrientation(event); 34 | }), 35 | [], 36 | ); 37 | 38 | return orientation; 39 | } 40 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useDocumentReadiness.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/react'; 2 | import { act, renderHook } from '@testing-library/react-hooks'; 3 | 4 | import { useDocumentReadiness } from '.'; 5 | 6 | test('change document readiness', () => { 7 | const { result } = renderHook(() => useDocumentReadiness()); 8 | 9 | act(() => { 10 | Object.defineProperty(document, 'readyState', { value: 'interactive' }); 11 | fireEvent(document, new ProgressEvent('readystatechange')); 12 | }); 13 | expect(result.current).toBe('interactive'); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useDocumentReadiness.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { canUseDOM, managedEventListener } from './utils'; 4 | 5 | /** 6 | * Tracks loading state of the page. 7 | * 8 | * @returns Readiness of the [`document`](https://developer.mozilla.org/docs/Web/API/Document), which is `'loading'` by default. 9 | * 10 | * @example 11 | * function Component() { 12 | * const documentReadiness = useDocumentReadiness(); 13 | * if (documentReadiness === 'interactive') { 14 | * // You may interact with any element of the document from now 15 | * } 16 | * // ... 17 | * } 18 | */ 19 | export default function useDocumentReadiness(): DocumentReadyState { 20 | const [readiness, setReadiness] = useState( 21 | canUseDOM ? document.readyState : 'loading', 22 | ); 23 | 24 | useEffect( 25 | () => 26 | managedEventListener(document, 'readystatechange', () => { 27 | setReadiness(document.readyState); 28 | }), 29 | [], 30 | ); 31 | 32 | return readiness; 33 | } 34 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useDocumentVisibility.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/react'; 2 | import { act, renderHook } from '@testing-library/react-hooks'; 3 | 4 | import { useDocumentVisibility } from '.'; 5 | 6 | test('change document visibility', () => { 7 | const { result } = renderHook(() => useDocumentVisibility()); 8 | expect(result.current).toBe('visible'); 9 | 10 | act(() => { 11 | Object.defineProperty(document, 'visibilityState', { value: 'hidden' }); 12 | fireEvent(document, new Event('visibilitychange')); 13 | }); 14 | expect(result.current).toBe('hidden'); 15 | }); 16 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useDocumentVisibility.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { canUseDOM, managedEventListener } from './utils'; 4 | 5 | /** 6 | * Tracks visibility of the page. 7 | * 8 | * @returns Visibility state of the [`document`](https://developer.mozilla.org/docs/Web/API/Document), which is `'visible'` by default. 9 | * 10 | * @example 11 | * function Component() { 12 | * const documentVisibility = useDocumentVisibility(); 13 | * if (documentVisibility === 'hidden') { 14 | * // Reduce resource utilization to aid background page performance 15 | * } 16 | * // ... 17 | * } 18 | */ 19 | export default function useDocumentVisibility(): VisibilityState { 20 | const [visibility, setVisibility] = useState( 21 | canUseDOM ? document.visibilityState : 'visible', // TODO: Consider using 'prerender' 22 | ); 23 | 24 | useEffect( 25 | () => 26 | managedEventListener(document, 'visibilitychange', () => { 27 | setVisibility(document.visibilityState); 28 | }), 29 | [], 30 | ); 31 | 32 | return visibility; 33 | } 34 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useEventListener.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { EventMap } from './types'; 4 | import { managedEventListener, useEventCallback } from './utils'; 5 | 6 | /** 7 | * Listens to an event while the enclosing component is mounted. 8 | * 9 | * @see [Event reference on MDN](https://developer.mozilla.org/en-US/docs/Web/Events) 10 | * 11 | * @param {EventTarget} target Target to listen on, possibly a DOM element or a remote service connector. 12 | * @param {string} type Name of event (case-sensitive). 13 | * @param {EventListener} callback Method to execute whenever the event fires. 14 | * @param options Additional listener characteristics. 15 | * 16 | * @example 17 | * function Component() { 18 | * useEventListener(window, 'error', () => { 19 | * console.log('A resource failed to load.'); 20 | * }); 21 | * // ... 22 | * } 23 | */ 24 | export default function useEventListener< 25 | T extends EventTarget, 26 | K extends keyof EventMap & string 27 | >( 28 | target: T, 29 | type: K, 30 | callback: (event: EventMap[K]) => void, 31 | options?: AddEventListenerOptions, 32 | ): void { 33 | // Based on the implementation of `useInterval` 34 | const savedCallback = useEventCallback(callback); 35 | 36 | useEffect(() => managedEventListener(target, type, savedCallback, options), [ 37 | options, 38 | savedCallback, 39 | target, 40 | type, 41 | ]); 42 | } 43 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useFocus.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | /** 4 | * Tracks focus state of an element. 5 | * 6 | * @returns Whether the element has focus, and props to be spread over the element under observation. 7 | * 8 | * @example 9 | * function Component() { 10 | * const [isFocused, bindFocus] = useFocus(); 11 | * // ... 12 | * return ; 13 | * } 14 | */ 15 | export default function useFocus(): [ 16 | boolean, 17 | Readonly<{ 18 | onFocus: () => void; 19 | onBlur: () => void; 20 | }>, 21 | ] { 22 | const [isFocused, setFocused] = useState(false); 23 | 24 | return [ 25 | isFocused, 26 | { 27 | onFocus(): void { 28 | setFocused(true); 29 | }, 30 | onBlur(): void { 31 | setFocused(false); 32 | }, 33 | }, 34 | ]; 35 | } 36 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useGeolocation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | /** 4 | * Tracks geolocation of the device. 5 | * 6 | * @param options Additional watching options. 7 | * @param errorCallback Method to execute in case of an error, e.g. when the user denies location sharing permissions. 8 | * @returns Locational data, or `undefined` when unavailable. 9 | * 10 | * @example 11 | * function Component() { 12 | * const geolocation = useGeolocation(); 13 | * if (geolocation) { 14 | * const { coords } = geolocation; 15 | * } 16 | * // ... 17 | * } 18 | */ 19 | export default function useGeolocation( 20 | options?: PositionOptions, 21 | errorCallback?: (error: PositionError) => void, 22 | ): Position | undefined { 23 | const [position, setPosition] = useState(); 24 | 25 | useEffect(() => { 26 | const id = navigator.geolocation.watchPosition( 27 | setPosition, 28 | errorCallback, 29 | options, 30 | ); 31 | return (): void => { 32 | navigator.geolocation.clearWatch(id); 33 | }; 34 | }, [errorCallback, options]); 35 | 36 | return position; 37 | } 38 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useHover.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | /** 4 | * Tracks hover state of an element. 5 | * 6 | * @param {boolean} disallowTouch Determines whether touch gestures should be ignored. 7 | * @returns Whether the element is hovered, and props to be spread over the element under observation. 8 | * 9 | * @example 10 | * function Component() { 11 | * const [isHovered, bindHover] = useHover(); 12 | * // ... 13 | * return ; 14 | * } 15 | */ 16 | export default function useHover( 17 | disallowTouch = false, 18 | ): [ 19 | boolean, 20 | Readonly<{ 21 | onMouseEnter: () => void; 22 | onMouseLeave: () => void; 23 | onTouchStart: () => void; 24 | onTouchEnd: () => void; 25 | }>, 26 | ] { 27 | const [isHovered, setHovered] = useState(false); 28 | 29 | return [ 30 | isHovered, 31 | { 32 | onMouseEnter(): void { 33 | setHovered(true); 34 | }, 35 | onMouseLeave(): void { 36 | setHovered(false); 37 | }, 38 | 39 | onTouchStart(): void { 40 | setHovered(!disallowTouch); 41 | }, 42 | onTouchEnd(): void { 43 | setHovered(false); 44 | }, 45 | }, 46 | ]; 47 | } 48 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useInterval.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | 3 | import { useInterval } from '.'; 4 | 5 | // Source: https://github.com/donavon/use-interval/blob/master/__tests__/index.test.js 6 | 7 | jest.useFakeTimers(); 8 | 9 | test('calls event handler each second', () => { 10 | const handler = jest.fn(); 11 | 12 | const { unmount } = renderHook(() => useInterval(handler, 1000)); 13 | expect(handler).toHaveBeenCalledTimes(0); 14 | 15 | jest.advanceTimersByTime(5000); 16 | expect(handler).toHaveBeenCalledTimes(5); 17 | 18 | unmount(); 19 | }); 20 | 21 | test('pausing the timer', () => { 22 | const handler = jest.fn(); 23 | 24 | const { unmount } = renderHook(() => useInterval(handler, null)); 25 | 26 | jest.runAllTimers(); 27 | expect(handler).toHaveBeenCalledTimes(0); 28 | 29 | unmount(); 30 | }); 31 | 32 | test('passing a new handler does not restart the timer', () => { 33 | const handler1 = jest.fn(); 34 | const handler2 = jest.fn(); 35 | 36 | const { rerender, unmount } = renderHook( 37 | ({ handler }) => useInterval(handler, 1000), 38 | { initialProps: { handler: handler1 } }, 39 | ); 40 | jest.advanceTimersByTime(500); 41 | 42 | rerender({ handler: handler2 }); 43 | 44 | jest.advanceTimersByTime(500); 45 | expect(handler1).toHaveBeenCalledTimes(0); 46 | expect(handler2).toHaveBeenCalledTimes(1); 47 | 48 | unmount(); 49 | }); 50 | 51 | test('passing a new delay cancels the timer and starts a new one', () => { 52 | const handler = jest.fn(); 53 | 54 | const { rerender, unmount } = renderHook( 55 | ({ delay }) => useInterval(handler, delay), 56 | { initialProps: { delay: 500 } }, 57 | ); 58 | jest.advanceTimersByTime(1000); 59 | expect(handler).toHaveBeenCalledTimes(2); 60 | 61 | rerender({ delay: 1000 }); 62 | 63 | jest.advanceTimersByTime(5000); 64 | expect(handler).toHaveBeenCalledTimes(7); 65 | 66 | unmount(); 67 | }); 68 | 69 | test('passing the same parameters causes no change in the timer', () => { 70 | const handler = jest.fn(); 71 | 72 | const { rerender, unmount } = renderHook(() => useInterval(handler, 1000)); 73 | jest.advanceTimersByTime(500); 74 | 75 | rerender(); 76 | 77 | jest.advanceTimersByTime(500); 78 | expect(handler).toHaveBeenCalledTimes(1); 79 | 80 | unmount(); 81 | }); 82 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useInterval.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { managedInterval, useEventCallback } from './utils'; 4 | 5 | /** 6 | * Repeatedly calls a function with a fixed time delay between each call. 7 | * 8 | * 📝 _Timings may be inherently inaccurate, due to the implementation of [`setInterval`](https://developer.mozilla.org/docs/Web/API/WindowOrWorkerGlobalScope/setInterval) under the hood._ 9 | * 10 | * @param callback Method to execute periodically. 11 | * @param delayMs Time, in milliseconds, to wait between executions of the specified function. Set to `null` for pausing. 12 | * 13 | * @example 14 | * function Component() { 15 | * useInterval(() => { 16 | * // Custom logic to execute each second 17 | * }, 1000); 18 | * // ... 19 | * } 20 | */ 21 | export default function useInterval( 22 | callback: () => void, 23 | delayMs: number | null, 24 | ): void { 25 | // Source: https://overreacted.io/making-setinterval-declarative-with-react-hooks/ 26 | const savedCallback = useEventCallback(callback); 27 | 28 | useEffect( 29 | () => 30 | delayMs != null ? managedInterval(savedCallback, delayMs) : undefined, 31 | [delayMs, savedCallback], 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useLanguagePreferences.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/react'; 2 | import { act, renderHook } from '@testing-library/react-hooks'; 3 | 4 | import { useLanguagePreferences } from '.'; 5 | 6 | test('change preferred languages', () => { 7 | const { result } = renderHook(() => useLanguagePreferences()); 8 | expect(result.current).toEqual(['en-US', 'en']); 9 | 10 | act(() => { 11 | Object.defineProperty(navigator, 'languages', { 12 | value: ['hu-HU', 'hu', 'en-US', 'en'], 13 | }); 14 | fireEvent(window, new Event('languagechange')); 15 | }); 16 | expect(result.current).toEqual(['hu-HU', 'hu', 'en-US', 'en']); 17 | }); 18 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useLanguagePreferences.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { canUseDOM, managedEventListener } from './utils'; 4 | 5 | function getPreferredLanguages(): ReadonlyArray { 6 | return navigator.languages || [navigator.language]; 7 | } 8 | 9 | /** 10 | * Tracks language preferences of the user. 11 | * 12 | * @returns An array of [BCP 47](https://tools.ietf.org/html/bcp47) language tags, ordered by preference with the most preferred language first. 13 | * 14 | * @example 15 | * function Component() { 16 | * const preferredLanguages = useLanguagePreferences(); 17 | * // ... 18 | * } 19 | */ 20 | export default function useLanguagePreferences(): ReadonlyArray { 21 | const [languages, setLanguages] = useState( 22 | canUseDOM ? getPreferredLanguages() : ['en-US', 'en'], 23 | ); 24 | 25 | useEffect( 26 | () => 27 | managedEventListener(window, 'languagechange', () => { 28 | setLanguages(getPreferredLanguages()); 29 | }), 30 | [], 31 | ); 32 | 33 | return languages; 34 | } 35 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import { JSONValue } from './types'; 2 | import useStorage from './useStorage'; 3 | 4 | const getLocalStorage = (): Storage => localStorage; 5 | 6 | /** 7 | * Stores a key/value pair statefully in [`localStorage`](https://developer.mozilla.org/docs/Web/API/Window/localStorage). 8 | * 9 | * @see [`useState` hook](https://reactjs.org/docs/hooks-reference.html#usestate), which exposes a similar interface 10 | * 11 | * @param key Identifier to associate the stored value with. 12 | * @param initialValue Value used when no item exists with the given key. Lazy initialization is available by using a function which returns the desired value. 13 | * @param errorCallback Method to execute in case of an error, e.g. when the storage quota has been exceeded or trying to store a circular data structure. 14 | * @returns A statefully stored value, and a function to update it. 15 | * 16 | * @example 17 | * function Component() { 18 | * const [visitCount, setVisitCount] = useLocalStorage('visitCount', 0); 19 | * useEffect(() => { 20 | * setVisitCount(count => count + 1); 21 | * }, []); 22 | * // ... 23 | * } 24 | */ 25 | export default function useLocalStorage( 26 | key: string, 27 | initialValue: T | (() => T) | null = null, 28 | errorCallback?: (error: DOMException | TypeError) => void, 29 | ): [T, React.Dispatch>] { 30 | return useStorage(getLocalStorage, key, initialValue, errorCallback); 31 | } 32 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useMedia.test.ts: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks'; 2 | 3 | import { useMedia } from '.'; 4 | 5 | test('evaluates media query', () => { 6 | const addListener = jest.fn(); 7 | const removeListener = jest.fn(); 8 | 9 | window.matchMedia = jest.fn().mockImplementation(() => ({ 10 | matches: false, 11 | addListener, 12 | removeListener, 13 | })); 14 | 15 | const { result, unmount } = renderHook(() => useMedia('(min-width: 600px)')); 16 | expect(result.current).toBe(false); 17 | expect(addListener).toHaveBeenCalled(); 18 | 19 | unmount(); 20 | expect(removeListener).toHaveBeenCalled(); 21 | }); 22 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useMedia.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { canUseDOM } from './utils'; 4 | 5 | /** 6 | * Tracks match state of a media query. 7 | * 8 | * @param query Media query to parse. 9 | * 10 | * @returns `true` if the associated media query list matches the state of the [`document`](https://developer.mozilla.org/docs/Web/API/Document), or `false` otherwise. 11 | * 12 | * @example 13 | * function Component() { 14 | * const isWidescreen = useMedia('(min-aspect-ratio: 16/9)'); 15 | * // ... 16 | * } 17 | */ 18 | export default function useMedia(query: string): boolean { 19 | const [matches, setMatches] = useState(() => 20 | canUseDOM ? matchMedia(query).matches : false, 21 | ); 22 | 23 | useEffect(() => { 24 | const mediaQueryList = matchMedia(query); 25 | 26 | function handleChange(): void { 27 | setMatches(mediaQueryList.matches); 28 | } 29 | 30 | // Handle `query` param changes immediately 31 | handleChange(); 32 | 33 | // TODO: Refactor to `managedEventListener` when `change` event is supported 34 | mediaQueryList.addListener(handleChange); 35 | return (): void => { 36 | mediaQueryList.removeListener(handleChange); 37 | }; 38 | }, [query]); 39 | 40 | return matches; 41 | } 42 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useMotionPreference.ts: -------------------------------------------------------------------------------- 1 | import useMedia from './useMedia'; 2 | 3 | /** 4 | * Tracks motion intensity preference of the user. 5 | * 6 | * @returns Preferred motion intensity. 7 | * 8 | * @example 9 | * function Component() { 10 | * const preferReducedMotion = useMotionPreference() === 'reduce'; 11 | * // ... 12 | * } 13 | */ 14 | export default function useMotionPreference(): 'no-preference' | 'reduce' { 15 | const isReduce = useMedia('(prefers-reduced-motion: reduce)'); 16 | 17 | if (isReduce) return 'reduce'; 18 | return 'no-preference'; 19 | } 20 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useMouseCoords.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/react'; 2 | import { act, renderHook } from '@testing-library/react-hooks'; 3 | 4 | import { useMouseCoords } from '.'; 5 | 6 | test('change window mouse coords', () => { 7 | const { result } = renderHook(() => useMouseCoords()); 8 | expect(result.current).toEqual([0, 0]); 9 | 10 | act(() => { 11 | fireEvent.mouseMove(window, { clientX: 11, clientY: 22 }); 12 | }); 13 | expect(result.current).toEqual([11, 22]); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useMouseCoords.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { managedEventListener } from './utils'; 4 | 5 | /** 6 | * Tracks mouse position. 7 | * 8 | * @returns Coordinates `[x, y]`, falling back to `[0, 0]` when unavailable. 9 | * 10 | * @example 11 | * function Component() { 12 | * const [mouseX, mouseY] = useMouseCoords(); 13 | * // ... 14 | * } 15 | */ 16 | export default function useMouseCoords(): Readonly<[number, number]> { 17 | const [coords, setCoords] = useState>([0, 0]); 18 | 19 | useEffect( 20 | () => 21 | managedEventListener(window, 'mousemove', (event) => { 22 | setCoords([event.clientX, event.clientY]); 23 | }), 24 | [], 25 | ); 26 | 27 | return coords; 28 | } 29 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useNetworkAvailability.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/react'; 2 | import { act, renderHook } from '@testing-library/react-hooks'; 3 | 4 | import { useNetworkAvailability } from '.'; 5 | 6 | test('change network availability', () => { 7 | const { result } = renderHook(() => useNetworkAvailability()); 8 | expect(result.current).toBe(true); 9 | 10 | act(() => { 11 | fireEvent(window, new Event('offline')); 12 | }); 13 | expect(result.current).toBe(false); 14 | }); 15 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useNetworkAvailability.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { canUseDOM, managedEventListener } from './utils'; 4 | 5 | /** 6 | * Tracks information about the network's availability. 7 | * 8 | * ⚠️ _This attribute is inherently unreliable. A computer can be connected to a network without having internet access._ 9 | * 10 | * @returns `false` if the user agent is definitely offline, or `true` if it might be online. 11 | * 12 | * @example 13 | * function Component() { 14 | * const isOnline = useNetworkAvailability(); 15 | * // ... 16 | * } 17 | */ 18 | export default function useNetworkAvailability(): boolean { 19 | const [online, setOnline] = useState(canUseDOM ? navigator.onLine : true); 20 | 21 | useEffect(() => { 22 | const cleanup1 = managedEventListener(window, 'offline', () => { 23 | setOnline(false); 24 | }); 25 | const cleanup2 = managedEventListener(window, 'online', () => { 26 | setOnline(true); 27 | }); 28 | 29 | return (): void => { 30 | cleanup1(); 31 | cleanup2(); 32 | }; 33 | }, []); 34 | 35 | return online; 36 | } 37 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useNetworkInformation.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { NetworkInformation } from './experimental-types/NetworkInformation.bundled'; 4 | import { canUseDOM, managedEventListener } from './utils'; 5 | 6 | /** 7 | * Tracks information about the device's network connection. 8 | * 9 | * ⚗️ _The underlying technology is experimental. Please be aware about browser compatibility before using this in production._ 10 | * 11 | * @returns Connection data, or `undefined` when unavailable. 12 | * 13 | * @example 14 | * function Component() { 15 | * const networkInformation = useNetworkInformation(); 16 | * if (networkInformation) { 17 | * const { effectiveType, downlink, rtt, saveData } = networkInformation; 18 | * } 19 | * // ... 20 | * } 21 | */ 22 | export default function useNetworkInformation(): 23 | | NetworkInformation 24 | | undefined { 25 | const [networkInformation, setNetworkInformation] = useState( 26 | canUseDOM ? navigator.connection : undefined, 27 | ); 28 | 29 | useEffect( 30 | () => 31 | navigator.connection 32 | ? managedEventListener(navigator.connection, 'change', () => { 33 | setNetworkInformation(navigator.connection); 34 | }) 35 | : undefined, 36 | [], 37 | ); 38 | 39 | return networkInformation; 40 | } 41 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useSessionStorage.ts: -------------------------------------------------------------------------------- 1 | import { JSONValue } from './types'; 2 | import useStorage from './useStorage'; 3 | 4 | const getSessionStorage = (): Storage => sessionStorage; 5 | 6 | /** 7 | * Stores a key/value pair statefully in [`sessionStorage`](https://developer.mozilla.org/docs/Web/API/Window/sessionStorage). 8 | * 9 | * @see [`useState` hook](https://reactjs.org/docs/hooks-reference.html#usestate), which exposes a similar interface 10 | * 11 | * @param key Identifier to associate the stored value with. 12 | * @param initialValue Value used when no item exists with the given key. Lazy initialization is available by using a function which returns the desired value. 13 | * @param errorCallback Method to execute in case of an error, e.g. when the storage quota has been exceeded or trying to store a circular data structure. 14 | * @returns A statefully stored value, and a function to update it. 15 | * 16 | * @example 17 | * function Component() { 18 | * const [name, setName] = useSessionStorage('name', 'Anonymous'); 19 | * // ... 20 | * } 21 | */ 22 | export default function useSessionStorage( 23 | key: string, 24 | initialValue: T | (() => T) | null = null, 25 | errorCallback?: (error: DOMException | TypeError) => void, 26 | ): [T, React.Dispatch>] { 27 | return useStorage(getSessionStorage, key, initialValue, errorCallback); 28 | } 29 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useSize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | /** 4 | * Tracks size of an element. 5 | * 6 | * ⚗️ _The underlying technology is experimental. Please be aware about browser compatibility before using this in production._ 7 | * 8 | * @param ref Attribute attached to the element under observation. 9 | * @param {TypeOf} ResizeObserverOverride Replacement for `window.ResizeObserver`, e.g. [a polyfill](https://github.com/juggle/resize-observer). 10 | * 11 | * @returns Dimensions `[width, height]`, falling back to `[0, 0]` when unavailable. 12 | * 13 | * @example 14 | * function Component() { 15 | * const ref = useRef(null); 16 | * const [width, height] = useSize(ref); 17 | * // ... 18 | * return ; 19 | * } 20 | */ 21 | export default function useSize( 22 | ref: React.RefObject, 23 | ResizeObserverOverride?: typeof ResizeObserver, 24 | ): Readonly<[number, number]> { 25 | const [size, setSize] = useState>([0, 0]); 26 | 27 | useEffect(() => { 28 | const ResizeObserver = ResizeObserverOverride || window.ResizeObserver; 29 | if (!ResizeObserver || !ref.current) return undefined; 30 | 31 | const observer = new ResizeObserver(([entry]) => { 32 | const { width, height } = entry.contentRect; 33 | setSize([width, height]); 34 | }); 35 | observer.observe(ref.current); 36 | 37 | return (): void => { 38 | observer.disconnect(); 39 | }; 40 | }, [ResizeObserverOverride, ref]); 41 | 42 | return size; 43 | } 44 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useStorage.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from 'react'; 2 | 3 | import { JSONValue } from './types'; 4 | import { dethunkify } from './utils'; 5 | 6 | export default function useStorage( 7 | getStorage: () => Storage | null, 8 | key: string, 9 | initialValue: T | (() => T) | null = null, 10 | errorCallback?: (error: DOMException | TypeError) => void, 11 | ): [T, React.Dispatch>] { 12 | const storage = useMemo(() => { 13 | try { 14 | // Check if the storage object is defined and available 15 | // Prior to Firefox 70, localStorage may be null 16 | return getStorage(); 17 | // eslint-disable-next-line no-empty 18 | } catch {} 19 | return null; 20 | }, [getStorage]); 21 | 22 | const [value, setValue] = useState(() => { 23 | const serializedValue = storage?.getItem(key); 24 | if (serializedValue == null) return dethunkify(initialValue); 25 | 26 | try { 27 | return JSON.parse(serializedValue); 28 | } catch { 29 | // Backwards compatibility with past stored non-serialized values 30 | return serializedValue; 31 | } 32 | }); 33 | 34 | useEffect(() => { 35 | if (storage) { 36 | try { 37 | storage.setItem(key, JSON.stringify(value)); 38 | } catch (error) { 39 | errorCallback?.(error); 40 | } 41 | } 42 | }, [errorCallback, key, storage, value]); 43 | 44 | return [value, setValue]; 45 | } 46 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useViewportScale.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { canUseDOM, managedEventListener } from './utils'; 4 | 5 | /** 6 | * Tracks visual viewport scale. 7 | * 8 | * ⚗️ _The underlying technology is experimental. Please be aware about browser compatibility before using this in production._ 9 | * 10 | * @returns Pinch-zoom scaling factor, falling back to `0` when unavailable. 11 | * 12 | * @example 13 | * function Component() { 14 | * const viewportScale = useViewportScale(); 15 | * // ... 16 | * } 17 | */ 18 | export default function useViewportScale(): number { 19 | const [scale, setScale] = useState( 20 | canUseDOM ? window.visualViewport.scale : 0, 21 | ); 22 | 23 | useEffect( 24 | () => 25 | managedEventListener(window.visualViewport, 'resize', () => { 26 | setScale(window.visualViewport.scale); 27 | }), 28 | [], 29 | ); 30 | 31 | return scale; 32 | } 33 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useViewportScrollCoords.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { canUseDOM, managedEventListener } from './utils'; 4 | 5 | /** 6 | * Tracks visual viewport scroll position. 7 | * 8 | * ⚗️ _The underlying technology is experimental. Please be aware about browser compatibility before using this in production._ 9 | * 10 | * @returns Coordinates `[x, y]`, falling back to `[0, 0]` when unavailable. 11 | * 12 | * @example 13 | * function Component() { 14 | * const [viewportScrollX, viewportScrollY] = useViewportScrollCoords(); 15 | * // ... 16 | * } 17 | */ 18 | export default function useViewportScrollCoords(): Readonly<[number, number]> { 19 | const [coords, setCoords] = useState>( 20 | canUseDOM 21 | ? [window.visualViewport.pageLeft, window.visualViewport.pageTop] 22 | : [0, 0], 23 | ); 24 | 25 | useEffect( 26 | () => 27 | managedEventListener(window.visualViewport, 'scroll', () => { 28 | setCoords([ 29 | window.visualViewport.pageLeft, 30 | window.visualViewport.pageTop, 31 | ]); 32 | }), 33 | [], 34 | ); 35 | 36 | return coords; 37 | } 38 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useViewportSize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { canUseDOM, managedEventListener } from './utils'; 4 | 5 | /** 6 | * Tracks visual viewport size. 7 | * 8 | * ⚗️ _The underlying technology is experimental. Please be aware about browser compatibility before using this in production._ 9 | * 10 | * @returns Dimensions `[width, height]`, falling back to `[0, 0]` when unavailable. 11 | * 12 | * @example 13 | * function Component() { 14 | * const [viewportWidth, viewportHeight] = useViewportSize(); 15 | * // ... 16 | * } 17 | */ 18 | export default function useViewportSize(): Readonly<[number, number]> { 19 | const [size, setSize] = useState>( 20 | canUseDOM 21 | ? [window.visualViewport.width, window.visualViewport.height] 22 | : [0, 0], 23 | ); 24 | 25 | useEffect( 26 | () => 27 | managedEventListener(window.visualViewport, 'resize', () => { 28 | setSize([window.visualViewport.width, window.visualViewport.height]); 29 | }), 30 | [], 31 | ); 32 | 33 | return size; 34 | } 35 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useWindowScrollCoords.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/react'; 2 | import { act, renderHook } from '@testing-library/react-hooks'; 3 | 4 | import { useWindowScrollCoords } from '.'; 5 | 6 | test('change window scroll coords', () => { 7 | const { result } = renderHook(() => useWindowScrollCoords()); 8 | expect(result.current).toEqual([0, 0]); 9 | 10 | act(() => { 11 | (window.pageXOffset as number) = 11; 12 | (window.pageYOffset as number) = 22; 13 | fireEvent.scroll(window); 14 | }); 15 | expect(result.current).toEqual([11, 22]); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useWindowScrollCoords.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { canUseDOM, managedEventListener } from './utils'; 4 | 5 | /** 6 | * Tracks window scroll position. 7 | * 8 | * @returns Coordinates `[x, y]`, falling back to `[0, 0]` when unavailable. 9 | * 10 | * @example 11 | * function Component() { 12 | * const [windowScrollX, windowScrollY] = useWindowScrollCoords(); 13 | * // ... 14 | * } 15 | */ 16 | export default function useWindowScrollCoords(): Readonly<[number, number]> { 17 | const [coords, setCoords] = useState>( 18 | canUseDOM ? [window.pageXOffset, window.pageYOffset] : [0, 0], 19 | ); 20 | 21 | useEffect( 22 | () => 23 | managedEventListener(window, 'scroll', () => { 24 | setCoords([window.pageXOffset, window.pageYOffset]); 25 | }), 26 | [], 27 | ); 28 | 29 | return coords; 30 | } 31 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useWindowSize.test.ts: -------------------------------------------------------------------------------- 1 | import { fireEvent } from '@testing-library/react'; 2 | import { act, renderHook } from '@testing-library/react-hooks'; 3 | 4 | import { useWindowSize } from '.'; 5 | 6 | test('change window size', () => { 7 | const { result } = renderHook(() => useWindowSize()); 8 | expect(result.current).toEqual([1024, 768]); 9 | 10 | act(() => { 11 | (window.innerWidth as number) = 1920; 12 | (window.innerHeight as number) = 1080; 13 | fireEvent(window, new UIEvent('resize')); 14 | }); 15 | expect(result.current).toEqual([1920, 1080]); 16 | }); 17 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/useWindowSize.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | import { canUseDOM, managedEventListener } from './utils'; 4 | 5 | /** 6 | * Tracks window size. 7 | * 8 | * @returns Dimensions `[width, height]`, falling back to `[0, 0]` when unavailable. 9 | * 10 | * @example 11 | * function Component() { 12 | * const [windowWidth, windowHeight] = useWindowSize(); 13 | * // ... 14 | * } 15 | */ 16 | export default function useWindowSize(): Readonly<[number, number]> { 17 | const [size, setSize] = useState>( 18 | canUseDOM ? [window.innerWidth, window.innerHeight] : [0, 0], 19 | ); 20 | 21 | useEffect( 22 | () => 23 | managedEventListener(window, 'resize', () => { 24 | setSize([window.innerWidth, window.innerHeight]); 25 | }), 26 | [], 27 | ); 28 | 29 | return size; 30 | } 31 | -------------------------------------------------------------------------------- /packages/web-api-hooks/src/utils.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useRef } from 'react'; 2 | 3 | import { EventMap } from './types'; 4 | 5 | export const canUseDOM = typeof window !== 'undefined'; 6 | 7 | export function dethunkify(value: T | (() => T)): T { 8 | return typeof value === 'function' ? (value as () => T)() : value; 9 | } 10 | 11 | export function managedEventListener< 12 | T extends EventTarget, 13 | K extends keyof EventMap & string 14 | >( 15 | target: T, 16 | type: K, 17 | callback: (event: EventMap[K]) => void, 18 | options?: AddEventListenerOptions, 19 | ): () => void { 20 | target.addEventListener(type, callback as EventListener, options); 21 | return (): void => { 22 | target.removeEventListener(type, callback as EventListener, options); 23 | }; 24 | } 25 | 26 | export function managedInterval( 27 | callback: () => void, 28 | delayMs: number, 29 | ): () => void { 30 | const id = setInterval(callback, delayMs); 31 | return (): void => { 32 | clearInterval(id); 33 | }; 34 | } 35 | 36 | export function useEventCallback( 37 | callback: T, 38 | ): (...args: unknown[]) => T { 39 | // Source: https://reactjs.org/docs/hooks-faq.html#how-to-read-an-often-changing-value-from-usecallback 40 | const ref = useRef(); 41 | 42 | useEffect(() => { 43 | ref.current = callback; 44 | }, [callback]); 45 | 46 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 47 | return useCallback((...args): T => ref.current!(...args), [ref]); 48 | } 49 | -------------------------------------------------------------------------------- /packages/web-api-hooks/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig", 3 | "exclude": ["**/*.test.*"] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "noEmit": true, 4 | "strict": true, 5 | "target": "ES2019", 6 | "module": "ESNext", 7 | "moduleResolution": "Node", 8 | "esModuleInterop": true, 9 | "isolatedModules": true, 10 | "jsx": "react" 11 | }, 12 | "exclude": ["**/pkg/"] 13 | } 14 | --------------------------------------------------------------------------------