├── .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 | [](https://travis-ci.com/github/kripod/react-hooks)
6 | [](https://lgtm.com/projects/g/kripod/react-hooks/context:javascript)
7 | [](https://codecov.io/gh/kripod/react-hooks)
8 | [](http://commitizen.github.io/cz-cli/)
9 | [](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 |
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 |
--------------------------------------------------------------------------------