├── .babelrc.js ├── .github └── workflows │ ├── ci.yml │ └── release-please.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── __mocks__ ├── IntersectionObserver.js └── fileMock.js ├── commitlint.config.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src ├── define.ts ├── index.ts ├── useIntersectionObserver │ ├── __tests__ │ │ └── index.spec.tsx │ ├── demo │ │ └── index.tsx │ └── index.tsx ├── useLoading │ ├── __tests__ │ │ └── index.spec.tsx │ ├── demo │ │ └── index.tsx │ └── index.tsx ├── useMeasure │ ├── __tests__ │ │ └── index.spec.tsx │ ├── demo │ │ └── index.tsx │ └── index.tsx ├── useOutClick │ ├── __tests__ │ │ └── index.spec.tsx │ ├── demo │ │ └── index.tsx │ └── index.tsx ├── usePortal │ ├── __tests__ │ │ └── index.spec.tsx │ ├── demo │ │ └── index.tsx │ └── index.tsx ├── usePrefetch │ ├── __tests__ │ │ ├── createResource.spec.ts │ │ └── index.spec.tsx │ ├── createResource.ts │ ├── demo │ │ └── index.tsx │ └── index.tsx └── useRestHeight │ ├── __tests__ │ └── index.spec.tsx │ ├── demo │ └── index.tsx │ └── index.tsx └── tsconfig.json /.babelrc.js: -------------------------------------------------------------------------------- 1 | // Copyright (c) 2014-present, Facebook, Inc. All rights reserved. 2 | 3 | module.exports = { 4 | presets: [ 5 | '@babel/preset-env', 6 | '@babel/preset-typescript', 7 | [ 8 | '@babel/preset-react', 9 | { runtime: 'automatic' } 10 | ] 11 | ] 12 | }; -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | runTSCheck: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v3 8 | - uses: actions/setup-node@v3 9 | with: 10 | node-version: '16' 11 | - run: npm install 12 | - name: Typescript check 13 | run: npm run ci 14 | runTest: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v3 18 | - uses: actions/setup-node@v3 19 | with: 20 | node-version: '16' 21 | - run: npm install 22 | - name: Run tests and collect coverage 23 | run: npm run test 24 | - name: Upload coverage to Codecov 25 | uses: codecov/codecov-action@v3 26 | runBuild: 27 | runs-on: ubuntu-latest 28 | steps: 29 | - uses: actions/checkout@v3 30 | - uses: actions/setup-node@v3 31 | with: 32 | node-version: '16' 33 | - run: npm install 34 | - name: Build resource 35 | run: npm run build 36 | -------------------------------------------------------------------------------- /.github/workflows/release-please.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | pull_request: 6 | branches: 7 | - master 8 | name: release-please 9 | jobs: 10 | release-please: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: google-github-actions/release-please-action@v3 14 | with: 15 | release-type: node 16 | package-name: microhook 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | es 4 | dist 5 | types -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npx --no -- commitlint --edit 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | . "$(dirname -- "$0")/_/husky.sh" 3 | 4 | npm run ci --noEmit 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [1.7.0](https://github.com/Ruimve/microhook/compare/v1.6.0...v1.7.0) (2023-06-26) 4 | 5 | 6 | ### Features 7 | 8 | * update docs ([25a20a8](https://github.com/Ruimve/microhook/commit/25a20a80e782fe5d97542bbbc2c7c532a411f368)) 9 | 10 | 11 | ### Bug Fixes 12 | 13 | * return parameters and catch error ([3d342be](https://github.com/Ruimve/microhook/commit/3d342beb9a2fb80130f6a81c7e8e0b66fcadda03)) 14 | 15 | ## [1.6.0](https://github.com/Ruimve/microhook/compare/v1.5.1...v1.6.0) (2023-04-16) 16 | 17 | 18 | ### Features 19 | 20 | * useIntersectionObserver ([b80b533](https://github.com/Ruimve/microhook/commit/b80b533acb977885fef824a8da46065417059eaf)) 21 | * useMeasure ([7300cc0](https://github.com/Ruimve/microhook/commit/7300cc01e205b6701e4596e2b51ff60c06a3fc90)) 22 | * useOutClick ([cb3de96](https://github.com/Ruimve/microhook/commit/cb3de969671937002014e79a2f65d84be1053e95)) 23 | * usePrefetch ([8fc4ec9](https://github.com/Ruimve/microhook/commit/8fc4ec9da6674020a61b67baa521973d7e33fc1b)) 24 | 25 | ## [1.5.1](https://github.com/Ruimve/microhook/compare/v1.5.0...v1.5.1) (2023-03-12) 26 | 27 | 28 | ### Miscellaneous Chores 29 | 30 | * release 1.5.1 ([475e32a](https://github.com/Ruimve/microhook/commit/475e32a09f89a5cdc8c5ca0f3e1bd057181f1efa)) 31 | 32 | ## [1.5.0](https://github.com/Ruimve/microhook/compare/v1.3.0...v1.5.0) (2023-03-12) 33 | 34 | 35 | ### Miscellaneous Chores 36 | 37 | * release 1.5.0 ([96f1f3e](https://github.com/Ruimve/microhook/commit/96f1f3e8cd225ca0d7ebc603d1224280c1556926)) 38 | 39 | ## [1.3.0](https://github.com/Ruimve/microhook/compare/v1.2.3...v1.3.0) (2023-03-03) 40 | 41 | 42 | ### Features 43 | 44 | * add ts tools ReturnTypeWithPromise<T> ([ad6ad3b](https://github.com/Ruimve/microhook/commit/ad6ad3b74b8ccba05abb91563997ebdf6327437c)) 45 | * useLoading parameters infer ([431b4e1](https://github.com/Ruimve/microhook/commit/431b4e1bd46bb88b0b2342777e1feab9b556f9a3)) 46 | 47 | 48 | ### Performance Improvements 49 | 50 | * ts ([28981ac](https://github.com/Ruimve/microhook/commit/28981acf1e44e105075c4e450071ac94e5f4d499)) 51 | 52 | ## [1.2.3](https://github.com/Ruimve/microhook/compare/v1.2.2...v1.2.3) (2023-01-27) 53 | 54 | 55 | ### Bug Fixes 56 | 57 | * change package name ([4aeb6be](https://github.com/Ruimve/microhook/commit/4aeb6be3689415d6b5660a129bd4c5e37f555022)) 58 | 59 | ## [1.2.2](https://github.com/Ruimve/microhook/compare/v1.2.1...v1.2.2) (2023-01-27) 60 | 61 | 62 | ### Miscellaneous Chores 63 | 64 | * release 1.2.2 ([8115d45](https://github.com/Ruimve/microhook/commit/8115d451440a95578b99bda1dc08315d7e4dbaf1)) 65 | 66 | ## [1.2.1](https://github.com/Ruimve/microhook/compare/v1.2.0...v1.2.1) (2023-01-23) 67 | 68 | 69 | ### Miscellaneous Chores 70 | 71 | * release 1.2.1 ([9b1d73b](https://github.com/Ruimve/microhook/commit/9b1d73b4b5b975bae658931d43c507fbd2de5d74)) 72 | 73 | ## [1.2.0](https://github.com/Ruimve/microhook/compare/v1.1.1...v1.2.0) (2023-01-22) 74 | 75 | 76 | ### Features 77 | 78 | * update version to 1.1.2 ([58e1db8](https://github.com/Ruimve/microhook/commit/58e1db826b71e94c29f74fd1d1a2d4d538d844d1)) 79 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for being willing to contribute! 4 | 5 | ## How should I write my commits? 6 | 7 | Please assumes you are using [Conventional Commit messages][conventional-commit-message]. 8 | 9 | The most important prefixes you should have in mind are: 10 | ``` 11 | fix: which represents bug fixes, and correlates to a SemVer patch. 12 | feat: which represents a new feature, and correlates to a SemVer minor. 13 | feat!:, or fix!:, refactor!:, etc., which represent a breaking change (indicated by the !) and will result in a SemVer major. 14 | ``` 15 | 16 | 17 | 18 | [conventional-commit-message]: https://www.conventionalcommits.org -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Ruimve 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 |
2 |

Microhook

3 | 4 | 5 | Ruimve 10 | 11 | 12 |

🔥🔥 Check out microhook!
🚀 It's a lightweight library that makes it easy to use React hooks in your code. 💻

13 |
14 |
15 | 16 | [![Build Status][build-badge]][build] 17 | [![Code Coverage][coverage-badge]][coverage] 18 | [![version][version-badge]][package] 19 | [![downloads][downloads-badge]][npmtrends] 20 | [![MIT License][license-badge]][license] 21 | [![PRs Welcome][prs-badge]][prs] 22 | 23 | [![Watch on GitHub][github-watch-badge]][github-watch] 24 | [![Star on GitHub][github-star-badge]][github-star] 25 | 26 | ## Table of Contents 27 | 28 | - [Documentation](#documentation) 29 | - [Introducing Hooks](#introducing-hooks) 30 | - [Installation](#installation) 31 | - [State](#state) 32 | - [useLoading](#useloading-simplify-your-async-requests) 33 | - [Side-effects](#side-effects) 34 | - [usePrefetch](#useprefetch-efficiently-prefetches-external-resources) 35 | - [Interaction](#layout) 36 | - [useRestHeight](#userestheight-calculate-remaining-height-dynamically) 37 | - [useIntersectionObserver](#useintersectionobserver-track-element-visibility-changes) 38 | - [useOutClick](#useoutclick-handle-outside-clicks-in-react) 39 | - [DOM](#dom) 40 | - [usePortal](#useportal-teleport-your-react-components-anywhere) 41 | - [useMeasure](#usemeasure-track-element-measurements-with-ease) 42 | 43 | ## Documentation 44 | 45 | *The latest usage documentation site is now available online! [Explore our new documentation site here!](https://microhook.netlify.app/)* 46 | 47 | *中文站点已经发布,[点击前往!](https://microhook.netlify.app/zh-cn/)* 48 | 49 | ## Introducing Hooks 50 | 51 | `Microhook` is a lightweight React Hooks library that aims to provide some excellent custom Hooks to help developers improve development efficiency and code quality. 💪 52 | 53 | `Microhook's` main features include: 54 | 55 | 👉 **Simplicity and ease of use**: The usage of each Hook is very simple and easy to understand, and the amount of code is very small. 56 | 57 | 🚀 **High efficiency and practicality**: Each Hook provided by `Microhook` is very practical and can be directly applied to projects, helping developers quickly solve some common problems. 58 | 59 | 💯 **Stable quality**: `Microhook` has been fully tested and validated, and the code quality is guaranteed, so you can use it with confidence. 60 | 61 | Microhook currently provides multiple Hooks, such as `useLoading`, `usePortal`, `useRestHeight`, etc. These Hooks can help you optimize your React projects, improve page performance and interaction experience. If you want to speed up your React development and improve your code quality, `Microhook` is definitely worth a try. 😎 62 | 63 | ## Installation 64 | 65 | This module is distributed via [npm][npm] which is bundled with [node][node] and 66 | should be installed as one of your project's `dependencies`: 67 | ``` 68 | npm install microhook --save 69 | ``` 70 | or 71 | 72 | for installation via [yarn][yarn]: 73 | ``` 74 | yarn add microhook 75 | ``` 76 | 77 | 78 | ## State 79 | 80 | ### useLoading: Simplify Your Async Requests! 81 | 82 | 👋 Hey there! Let me introduce you to `useLoading` -- a custom React hook that makes handling the loading state of an API request or Promise a breeze. 🌬️ 83 | 84 | 💻 By utilizing `useLoading`, you can easily implement an elegant solution to handle asynchronous requests in your React application. With just a few lines of code, you can keep track of loading status and display a spinner or loading message to keep your users informed of the ongoing process. 🚀 85 | 86 | 🤔 Still not sure how it works? Let's break it down: 87 | 88 | * A function that takes any number of arguments and returns a Promise. 89 | * An optional array of arguments. 90 | * An optional error type. 91 | 92 | 🎉 The return value of `useLoading` is a tuple, containing two items: 93 | 94 | An object with two properties, `loading` and `data`. `loading` is a boolean that indicates whether the request is currently being processed or not. `data` is an object containing the response data from the request. 95 | 96 | An object with a single property, `wrapRequest`, which is an asynchronous function that wraps the original request and handles the loading state. 97 | 98 | 💡 Here's a quick example of how to use `useLoading` in your code: 99 | 100 | ```tsx 101 | import { useLoading } from 'microhook'; 102 | 103 | async function fetchSomeData(arg1: string, arg2: number): Promise<{ message: string }> { 104 | // fetch data here... 105 | } 106 | 107 | function MyComponent() { 108 | const [response, { wrapRequset }] = useLoading(fetchSomeData); 109 | 110 | useEffect(() => { 111 | wrapRequset('some argument', 123); 112 | }, []); 113 | 114 | if (response.loading) { 115 | return ; 116 | } 117 | 118 | return
{response.data?.message}
; 119 | } 120 | ``` 121 | 122 | 🎓 As you can see, `useLoading` simplifies handling the loading state and error handling of an API request or Promise, allowing you to focus on the core functionality of your application. Give it a try and let me know what you think! 🤩 [For more information!][use-loading-demo] 123 | 124 | ## Side-effects 125 | 126 | ### usePrefetch: Efficiently Prefetches External Resources! 127 | 128 | 👋 Hi there! Let me introduce you to `usePrefetch`, a React hook for prefetching resources like images, scripts, and stylesheets. 129 | 130 | 🤔 Why is it useful? By preloading resources, `usePrefetch` can improve perceived performance and reduce the likelihood of visible loading spinners or other loading indicators. This can make the app feel more responsive and improve the user experience. 131 | 132 | To use it, first import it from your React component: 133 | 134 | ```tsx 135 | import { usePrefetch } from 'microhook'; 136 | ``` 137 | 138 | Then, call the hook with an array of URLs and an optional options object: 139 | 140 | ```tsx 141 | const urls = ['https://example.com/image1.jpg', 'https://example.com/image2.jpg']; 142 | const options = { type: 'link' }; 143 | usePrefetch(urls, options); 144 | ``` 145 | 146 | This will asynchronously fetch the resources and cache them for later use, improving your website's performance. 🏎️ [For more information!][use-prefetch-demo] 147 | 148 | ## Interaction 149 | 150 | ### useRestHeight: Calculate Remaining Height Dynamically! 151 | 152 | 👋 Hey there! Let me introduce you to `useRestHeight`, a React hook that calculates the remaining height of a container after subtracting the height of its child elements and any specified offsets. 153 | 154 | 🚀 This hook is perfect when you want to dynamically adjust the layout of a container based on its available height. 155 | 156 | 👉 To use this hook, simply import it from the corresponding module and call it inside a functional component with three arguments: 157 | 158 | * **parent**: A string or a React ref object that refers to the container element whose height you want to calculate. 159 | * **children**: An array of strings or React ref objects that refer to the child elements whose heights you want to subtract from the parent height. 160 | * **offsets**: An array of numbers that specify any additional height offsets that you want to subtract from the parent height. 161 | 162 | 🎉 The hook returns an array with two elements: 163 | 164 | * **restHeight**: The remaining height of the container after subtracting the child elements and offsets. 165 | * **action**: An object with a single function recalculateHeight that you can call to recalculate the container height when needed. 166 | 167 | 💡 Overall, `useRestHeight` simplifies the process of dynamically adjusting the layout of container elements in your React applications. Want to see a demo? [Check it out!][use-rest-height-demo] 168 | 169 | ### useIntersectionObserver: Track element visibility changes. 170 | 171 | This hook allows you to detect when an element becomes visible in the viewport. You can use it to implement lazy-loading, infinite scrolling, or any other functionality that requires you to track an element's visibility. 172 | 173 | To use it, you simply need to pass a RefObject to the element you want to observe, along with any optional configuration options like the root element, root margin, and threshold. The hook returns a tuple containing the IntersectionObserverEntry for the observed element, and an empty object that you can use to dispatch any actions. 174 | 175 | So, if you want to make your web app more performant and user-friendly, give useIntersectionObserver a try! 👍[Demo is here][use-intersection-observer-demo] 176 | 177 | ### useOutClick: Handle Outside Clicks In React! 178 | 179 | The code exports a single custom hook, `useOutClick`, which takes a handler function as a parameter and returns a tuple containing a ref object and an empty action object. Here's how to use it: 180 | 181 | * **Import**: Import the `useOutClick` hook from its source file using the following code: 182 | 183 | ```tsx 184 | import { useOutClick } from 'microhook'; 185 | ``` 186 | 187 | * Usage: Use the `useOutClick` hook in your functional component as follows: 188 | 189 | ```tsx 190 | const MyComponent = () => { 191 | const handleClickOutside = () => { 192 | // do something when user clicks outside of the element 193 | }; 194 | 195 | const ref = useOutClick(handleClickOutside); 196 | 197 | return ( 198 |
199 | {/* content of the element to monitor */} 200 |
201 | ); 202 | }; 203 | ``` 204 | 205 | In this example, we define a `handleClickOutside` function that will be called when the user clicks outside of the element. We then call the `useOutClick` hook, passing in the `handleClickOutside` function. The `useOutClick` hook returns a tuple containing a ref that we attach to the element we want to monitor, in this case a `div`, and an empty action object. 206 | 207 | * **Types**: The `useOutClick` hook is a generic function that takes a type parameter `T` that extends `HTMLElement`. This allows TypeScript to ensure that the ref object returned by the hook is properly typed to the monitored element. 208 | 209 | That's it! Now you can detect when the user clicks outside of a specified element and take appropriate action.[Demo is here][use-intersection-observer-demo] 210 | 211 | ## DOM 212 | 213 | ### usePortal: Teleport Your React Components Anywhere! 214 | 215 | 🚀 `usePortal` is a React hook that allows you to easily render content outside of the component hierarchy. Simply pass in a render function and a container, and `usePortal` will take care of the rest. It's perfect for creating modals, tooltips, and other UI elements that need to be rendered outside of the main content area. 216 | 217 | Here's an example of how to use it: 218 | 219 | ```tsx 220 | import { usePortal } from 'microhook'; 221 | 222 | function MyModal() { 223 | const { render } = usePortal(() => ( 224 |
225 |

Modal title

226 |

Modal content goes here...

227 |
228 | ), document.body); 229 | 230 | return render(); 231 | } 232 | ``` 233 | 234 | 👉 Make sure to wrap the render function in `useCallback` and memoize your component with `React.memo` for optimal performance. [For more information!][use-portal-demo] 235 | 236 | ### useMeasure: Track Element Measurements With Ease! 237 | 238 | To use the `useMeasure` hook, first import it into your component with the following code: 239 | 240 | ```tsx 241 | import { useMeasure } from 'microhook'; 242 | ``` 243 | 244 | Then, declare a ref for the element you want to measure with the `useRef` hook: 245 | 246 | ```tsx 247 | const ref = useRef(null); 248 | ``` 249 | 250 | Finally, call the `useMeasure` hook with the ref as an argument, and destructure the `measure` object from the returned value: 251 | 252 | ```tsx 253 | const [measure] = useMeasure(ref); 254 | ``` 255 | 256 | You can now access the measurements of the element in your component with `measure.width`, `measure.height`, `measure.top`, `measure.right`,`measure.bottom`, `measure.left`, `measure.x`, and `measure.y`. Whenever the size or position of the element changes, the measure object will automatically update with the new values. [For more information!][use-measure-demo] 257 | 258 | 259 | [npm]: https://www.npmjs.com/ 260 | [yarn]: https://classic.yarnpkg.com 261 | [node]: https://nodejs.org 262 | [portals]: https://reactjs.org/docs/portals.html#gatsby-focus-wrapper 263 | [build-badge]:https://img.shields.io/github/workflow/status/microhook/validate?logo=github&style=flat-square 264 | [build]: https://github.com/Ruimve/microhook/actions/workflows/ci.yml/badge.svg 265 | [coverage-badge]: https://img.shields.io/codecov/c/github/Ruimve/microhook.svg?style=flat-square 266 | [coverage]: https://codecov.io/github/microhook 267 | [version-badge]: https://img.shields.io/npm/v/microhook.svg?style=flat-square 268 | [package]: https://www.npmjs.com/package/microhook 269 | [downloads-badge]: https://img.shields.io/npm/dm/microhook.svg?style=flat-square 270 | [npmtrends]: http://www.npmtrends.com/microhook 271 | [license-badge]: https://img.shields.io/npm/l/microhook.svg?style=flat-square 272 | [license]: https://github.com/Ruimve/microhook/blob/master/LICENSE 273 | [prs-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square 274 | [prs]: http://makeapullrequest.com 275 | [github-watch-badge]: https://img.shields.io/github/watchers/Ruimve/microhook.svg?style=social 276 | [github-watch]: https://github.com/Ruimve/microhook/watchers 277 | [github-star-badge]: https://img.shields.io/github/stars/Ruimve/microhook.svg?style=social 278 | [github-star]: https://github.com/Ruimve/microhook/stargazers 279 | [hooks]: https://react.docschina.org/docs/hooks-custom.html 280 | [resize-observer]: https://developer.mozilla.org/zh-CN/docs/Web/API/ResizeObserver 281 | 282 | [use-loading-demo]: https://github.com/Ruimve/microhook/blob/master/src/useLoading/demo/index.tsx 283 | [use-rest-height-demo]: https://github.com/Ruimve/microhook/blob/master/src/useRestHeight/demo/index.tsx 284 | [use-portal-demo]: https://github.com/Ruimve/microhook/blob/master/src/usePortal/demo/index.tsx 285 | [use-prefetch-demo]: https://github.com/Ruimve/microhook/blob/master/src/usePrefetch/demo/index.tsx 286 | [use-intersection-observer-demo]: https://github.com/Ruimve/microhook/blob/master/src/useIntersectionObserver/demo/index.tsx 287 | [use-measure-demo]: https://github.com/Ruimve/microhook/blob/master/src/useMeasure/demo/index.tsx 288 | [use-intersection-observer-demo]: https://github.com/Ruimve/microhook/blob/master/src/useOutClick/demo/index.tsx 289 | -------------------------------------------------------------------------------- /__mocks__/IntersectionObserver.js: -------------------------------------------------------------------------------- 1 | class IntersectionObserver { 2 | options; 3 | callback; 4 | observerId; 5 | 6 | constructor(callback, options) { 7 | this.callback = callback; 8 | this.options = options || {}; 9 | 10 | this.observerId = setTimeout(() => { 11 | this.callback([{ 12 | isIntersecting: true, 13 | intersectionRatio: 1, 14 | target: document.createElement('div'), 15 | boundingClientRect: {}, 16 | intersectionRect: {}, 17 | rootBounds: {}, 18 | }]); 19 | }, 1000); 20 | } 21 | 22 | disconnect() { 23 | clearInterval(this.observerId); 24 | } 25 | 26 | observe() { } 27 | unobserve() { } 28 | } 29 | 30 | export { 31 | IntersectionObserver 32 | } -------------------------------------------------------------------------------- /__mocks__/fileMock.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Ruimve/microhook/857b62eff8069361adb15c57aaf22c5bbb4738b6/__mocks__/fileMock.js -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | 2, 6 | 'always', 7 | [ 8 | 'build', 9 | 'chore', 10 | 'ci', 11 | 'docs', 12 | 'feat', 13 | 'fix', 14 | 'perf', 15 | 'refactor', 16 | 'revert', 17 | 'style', 18 | 'test' 19 | ] 20 | ] 21 | } 22 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "microhook", 3 | "version": "1.7.0", 4 | "description": "🔥🔥 Check out microhook! 🚀 It's a lightweight library that makes it easy to use React hooks in your code. 💻", 5 | "main": "es/index.js", 6 | "types": "types/index.d.ts", 7 | "author": "Ruimve", 8 | "license": "MIT", 9 | "scripts": { 10 | "build": "rollup -c --bundleConfigAsCjs", 11 | "ci": "tsc --noEmit", 12 | "test": "jest", 13 | "prepare": "husky install" 14 | }, 15 | "files": [ 16 | "es", 17 | "types" 18 | ], 19 | "keywords": [ 20 | "react", 21 | "hook", 22 | "state-hook", 23 | "effect-hook", 24 | "hooks-api" 25 | ], 26 | "repository": { 27 | "type": "git", 28 | "url": "git+https://github.com/Ruimve/microhook.git" 29 | }, 30 | "bugs": { 31 | "url": "https://github.com/Ruimve/microhook/issues" 32 | }, 33 | "homepage": "https://github.com/Ruimve/microhook#readme", 34 | "dependencies": { 35 | "lodash": "^4.17.21", 36 | "react": "^18.2.0", 37 | "react-dom": "^18.2.0", 38 | "typescript": "^4.9.4" 39 | }, 40 | "devDependencies": { 41 | "@babel/core": "^7.11.6", 42 | "@babel/preset-env": "^7.1.0", 43 | "@babel/preset-react": "^7.12.1", 44 | "@babel/preset-typescript": "^7.0.0", 45 | "@commitlint/cli": "^17.4.0", 46 | "@commitlint/config-conventional": "^17.4.0", 47 | "@rollup/plugin-commonjs": "^24.0.0", 48 | "@rollup/plugin-node-resolve": "^15.0.1", 49 | "@rollup/plugin-terser": "^0.3.0", 50 | "@rollup/plugin-typescript": "^10.0.1", 51 | "@testing-library/jest-dom": "^5.16.5", 52 | "@testing-library/react": "^13.4.0", 53 | "@testing-library/user-event": "^14.4.3", 54 | "@types/jest": "^29.2.4", 55 | "@types/lodash": "^4.14.191", 56 | "@types/node": "^18.11.18", 57 | "@types/react": "^18.0.26", 58 | "@types/react-dom": "^18.0.10", 59 | "babel-jest": "^29.3.1", 60 | "husky": "^8.0.3", 61 | "jest": "^29.3.1", 62 | "jest-environment-jsdom": "^29.3.1", 63 | "rollup": "^3.9.0", 64 | "tslib": "^2.4.1" 65 | }, 66 | "jest": { 67 | "testEnvironment": "jsdom", 68 | "testMatch": [ 69 | "**/*.spec.{js,jsx,ts,tsx}" 70 | ], 71 | "collectCoverage": true, 72 | "coverageReporters": [ 73 | "text", 74 | "cobertura" 75 | ] 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import { nodeResolve } from '@rollup/plugin-node-resolve'; 2 | import typescript from '@rollup/plugin-typescript'; 3 | import commonjs from '@rollup/plugin-commonjs'; 4 | import terser from '@rollup/plugin-terser'; 5 | import pkg from './package.json'; 6 | 7 | export default [ 8 | { 9 | input: "src/index.ts", 10 | output: { 11 | file: pkg.main, 12 | format: 'es' 13 | }, 14 | external: ['react', 'react-dom', 'lodash'], 15 | plugins: [ 16 | nodeResolve(), 17 | typescript({ 18 | compilerOptions: { 19 | declaration: false 20 | } 21 | }), 22 | commonjs(), 23 | terser() 24 | ] 25 | }, 26 | { 27 | input: "src/index.ts", 28 | output: { 29 | file: pkg.types, 30 | format: 'es' 31 | }, 32 | external: ['react', 'react-dom', 'lodash'], 33 | plugins: [ 34 | typescript() 35 | ] 36 | } 37 | ] -------------------------------------------------------------------------------- /src/define.ts: -------------------------------------------------------------------------------- 1 | export type ReturnValue = [T, P]; 2 | 3 | export type ReturnTypeWithPromise Promise> = T extends (...args: any[]) => Promise ? R : any; 4 | 5 | export function isType(element: any, predicate: () => boolean): element is T { 6 | return predicate(); 7 | } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { useLoading } from './useLoading'; 2 | import { useRestHeight } from './useRestHeight'; 3 | import { usePortal } from './usePortal'; 4 | import { usePrefetch } from './usePrefetch'; 5 | import { useIntersectionObserver } from './useIntersectionObserver'; 6 | import { useMeasure } from './useMeasure'; 7 | export { 8 | useLoading, 9 | useRestHeight, 10 | usePortal, 11 | usePrefetch, 12 | useIntersectionObserver, 13 | useMeasure 14 | } -------------------------------------------------------------------------------- /src/useIntersectionObserver/__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react'; 2 | import { IntersectionObserver as MockApi } from '../../../__mocks__/IntersectionObserver'; 3 | import { useIntersectionObserver } from '../index'; 4 | 5 | describe('useIntersectionObserver', () => { 6 | beforeAll(() => { 7 | window.IntersectionObserver = MockApi as unknown as typeof IntersectionObserver; 8 | }) 9 | it('should set the intersectionObserverEntry to null when the ref is null', () => { 10 | const ref = { current: null }; 11 | const { result } = renderHook(() => 12 | useIntersectionObserver(ref, { 13 | rootMargin: '0px', 14 | threshold: 0, 15 | }) 16 | ); 17 | const [intersectionObserverEntry] = result.current; 18 | expect(intersectionObserverEntry).toBeNull(); 19 | }); 20 | 21 | it('should set the intersectionObserverEntry to null when the entry is not intersecting', () => { 22 | const ref = { current: document.createElement('div') }; 23 | const { result } = renderHook(() => 24 | useIntersectionObserver(ref, {}) 25 | ); 26 | const [intersectionObserverEntry] = result.current; 27 | expect(intersectionObserverEntry).toBeNull(); 28 | }); 29 | 30 | it('should set the intersectionObserverEntry to a non-null value when the entry is intersecting', () => { 31 | const getBoundingClientRect = jest.fn() 32 | .mockReturnValue({ 33 | top: 0, 34 | left: 0, 35 | bottom: 100, 36 | right: 100, 37 | width: 100, 38 | height: 100 39 | }); 40 | HTMLElement.prototype.getBoundingClientRect = getBoundingClientRect; 41 | const ref = { current: document.createElement('div') }; 42 | jest.useFakeTimers(); 43 | const { result } = renderHook(() => 44 | useIntersectionObserver(ref, { 45 | rootMargin: '0px', 46 | threshold: 0, 47 | }) 48 | ); 49 | act(() => { 50 | jest.runAllTimers() 51 | }); 52 | 53 | jest.useFakeTimers(); 54 | const observer = new IntersectionObserver((entries) => { 55 | const [entry] = entries; 56 | const [intersectionObserverEntry] = result.current; 57 | expect(intersectionObserverEntry).toEqual(entry); 58 | }); 59 | observer.observe(ref.current); 60 | act(() => { 61 | jest.runAllTimers() 62 | }); 63 | 64 | }); 65 | }); -------------------------------------------------------------------------------- /src/useIntersectionObserver/demo/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { useIntersectionObserver } from '../index'; 3 | 4 | function App() { 5 | const targetRef = useRef(null); 6 | const [entry] = useIntersectionObserver(targetRef, { 7 | root: null, 8 | rootMargin: '0px', 9 | threshold: 0, 10 | }); 11 | 12 | return ( 13 |
14 |
21 | {entry?.isIntersecting ? 'In view!' : 'Out of view'} 22 |
23 |
24 | ); 25 | } 26 | 27 | export default App; 28 | 29 | 30 | -------------------------------------------------------------------------------- /src/useIntersectionObserver/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState, RefObject } from 'react'; 2 | import { ReturnValue } from '../define'; 3 | 4 | interface IntersectionObserverProps { 5 | root?: HTMLElement | null; // The element that is used as the viewport for checking the visibility of the target element. 6 | rootMargin?: string; // Margin around the root element. 7 | threshold?: number | number[]; // A single number or an array of numbers indicating at what percentage of the target's visibility the observer's callback should be executed. 8 | } 9 | 10 | interface Action { } 11 | 12 | /** 13 | * A hook that uses the Intersection Observer API to track whether a specified HTML element is currently visible on the screen. 14 | * @param ref A React ref object that holds a reference to the HTML element to be observed. 15 | * @param options An object that holds optional values for the root element, margin around the root element, and threshold percentage for triggering the observer's callback. 16 | * @returns An array of two values: the current IntersectionObserverEntry object representing the observed element's intersection data, and an empty action object. 17 | */ 18 | export function useIntersectionObserver( 19 | ref: RefObject, 20 | { 21 | root = null, 22 | rootMargin = '0px', 23 | threshold = 0, 24 | }: IntersectionObserverProps 25 | ): ReturnValue { 26 | const [intersectionObserverEntry, setIntersectionObserverEntry] = useState(null); 27 | 28 | useEffect(() => { 29 | // Create an Intersection Observer object 30 | const observer = new IntersectionObserver( 31 | (entries) => { 32 | const [entry] = entries; 33 | setIntersectionObserverEntry(entry); // Update state with the intersection observer entry 34 | }, 35 | { 36 | root, 37 | rootMargin, 38 | threshold, 39 | } 40 | ); 41 | 42 | // Start observing the target element 43 | if (ref.current) { 44 | observer.observe(ref.current); 45 | } 46 | 47 | // Stop observing the target element when the component unmounts or when the options change 48 | return () => { 49 | if (ref.current) { 50 | observer.unobserve(ref.current); 51 | } 52 | }; 53 | }, [root, rootMargin, threshold]); 54 | 55 | return [intersectionObserverEntry, {}]; 56 | } -------------------------------------------------------------------------------- /src/useLoading/__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from "@testing-library/react"; 2 | import { act } from "react-dom/test-utils"; 3 | 4 | import { useLoading } from '../index'; 5 | 6 | /** 7 | * Simulate a request 8 | * @returns Promise 9 | */ 10 | 11 | describe('Test useLoading', () => { 12 | it('loading status', async () => { 13 | const people = [{ name: 'Xiao Ming', age: 18 }]; 14 | const callback = jest.fn(); 15 | callback.mockImplementation(() => { 16 | return new Promise((resolve) => { 17 | setTimeout(() => { 18 | resolve(people); 19 | }, 1000); 20 | }); 21 | }); 22 | const { result } = renderHook(() => useLoading(callback)); 23 | expect(result.current[0].loading).toEqual(false); 24 | act(() => { 25 | result.current[1].wrapRequset(); 26 | }); 27 | expect(result.current[0].loading).toEqual(true); 28 | }); 29 | 30 | it('get Xiao Ming information after 1s', async () => { 31 | const people = [{ name: 'Xiao Ming', age: 18 }]; 32 | const callback = jest.fn(); 33 | callback.mockImplementation(() => { 34 | return new Promise((resolve) => { 35 | setTimeout(() => { 36 | resolve(people); 37 | }, 1000); 38 | }); 39 | }); 40 | const { result } = renderHook(() => useLoading(callback)); 41 | await act(async () => { 42 | await result.current[1].wrapRequset(); 43 | }); 44 | expect(result.current[0].data).toEqual(people); 45 | }); 46 | 47 | it('exception handling', async () => { 48 | const error = { message: 'exception caught' }; 49 | const callback = jest.fn(); 50 | callback.mockImplementation(() => { 51 | return new Promise((resolve, reject) => { 52 | setTimeout(() => { 53 | reject(error); 54 | }, 1000); 55 | }); 56 | }); 57 | const { result } = renderHook(() => useLoading(callback)); 58 | await act(async () => { 59 | await result.current[1].wrapRequset(); 60 | }); 61 | 62 | expect(result.current[0].data).toEqual(null); 63 | }); 64 | 65 | 66 | it('second times if error return prvious value', async () => { 67 | const people = [{ name: 'Xiao Ming', age: 18 }]; 68 | const error = { message: 'exception caught' }; 69 | let count = 0; 70 | const callback = jest.fn(); 71 | callback.mockImplementation(() => { 72 | if(count === 0){ 73 | return new Promise((resolve) => { 74 | setTimeout(() => { 75 | count = 1 76 | resolve(people); 77 | }, 1000); 78 | }); 79 | }else{ 80 | return new Promise((resolve, reject) => { 81 | setTimeout(() => { 82 | reject(error); 83 | }, 1000); 84 | }); 85 | } 86 | }); 87 | const { result } = renderHook(() => useLoading(callback)); 88 | await act(async () => { 89 | await result.current[1].wrapRequset(); 90 | }); 91 | expect(result.current[0].data).toEqual(people); 92 | 93 | /** second times if error return prvious value **/ 94 | await act(async () => { 95 | await result.current[1].wrapRequset(); 96 | }); 97 | 98 | expect(result.current[0].data).toEqual(people); 99 | 100 | }); 101 | 102 | }); -------------------------------------------------------------------------------- /src/useLoading/demo/index.tsx: -------------------------------------------------------------------------------- 1 | import { useLoading } from '../index'; 2 | /** 3 | * @input 4 | * import { useLoading } from 'microhook'; 5 | */ 6 | 7 | function fetchData(keyword: string) { 8 | return new Promise((resolve, reject) => { 9 | setTimeout(() => { 10 | resolve(keyword + ': data received') 11 | }, 2000) 12 | }) 13 | } 14 | 15 | function Demo() { 16 | const [result, { wrapRequset: requestData }] = useLoading(fetchData); 17 | 18 | const handleClick = () => { 19 | requestData('1'); 20 | } 21 | 22 | return
23 | 24 |
25 | { 26 | result.loading ? 'loading' : result.data 27 | } 28 |
29 |
30 | } 31 | 32 | export { 33 | Demo 34 | } -------------------------------------------------------------------------------- /src/useLoading/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Ruimve 3 | * @description Handles the pending status of API or Promise. 4 | */ 5 | 6 | import { useState, useCallback } from "react"; 7 | import { ReturnTypeWithPromise, ReturnValue } from '../define'; 8 | 9 | type IFunction = (...args: any[]) => Promise; 10 | type IArray = any[]; 11 | type IReturn = any; 12 | 13 | interface Response { 14 | loading: boolean; // Whether the request is loading 15 | data: T | null; // The data returned by the request 16 | } 17 | 18 | interface Action { 19 | wrapRequset: (...args: T) => Promise; // A function that wraps the request and handles loading state 20 | } 21 | 22 | function createResponse(loading: boolean, data: T | null = null): Response { 23 | return { 24 | loading, 25 | data 26 | } 27 | } 28 | 29 | function useLoading< 30 | T extends IFunction, 31 | P extends IArray = Parameters, 32 | U extends IReturn = ReturnTypeWithPromise 33 | >(request: IFunction): ReturnValue, Action> { 34 | // Use React hook to manage state 35 | const [response, setResponse] = useState>({ loading: false, data: null }); 36 | 37 | const wrapRequset = useCallback( 38 | async (...args: P) => { 39 | try { 40 | setResponse(createResponse(true, response.data)); // Set loading state to true 41 | const data = await request(...args); // Execute request 42 | setResponse(createResponse(false, data)); // Set loading state to false and store the response data 43 | return data; 44 | } catch (e) { 45 | setResponse(createResponse(false, response.data)); 46 | return response.data; 47 | } 48 | }, 49 | [request, response] 50 | ) 51 | 52 | return [response, { wrapRequset }] 53 | } 54 | 55 | export { 56 | useLoading 57 | } 58 | -------------------------------------------------------------------------------- /src/useMeasure/__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, renderHook, screen } from '@testing-library/react'; 2 | import { useMeasure, Measure } from '../'; 3 | 4 | function mockResizeObserver(contentRect: Measure) { 5 | window.ResizeObserver = 6 | function (callback: (entries: { contentRect: Measure }[]) => void) { 7 | callback([{ contentRect }]); 8 | return { 9 | observe: jest.fn(), 10 | disconnect: jest.fn(), 11 | unobserve: jest.fn() 12 | } 13 | } as any; 14 | } 15 | 16 | describe('useMeasure', () => { 17 | it('should return correct initial value', () => { 18 | const noElement = { width: undefined, height: undefined, top: undefined, right: undefined, bottom: undefined, left: undefined, x: undefined, y: undefined }; 19 | 20 | mockResizeObserver(noElement); 21 | 22 | const { result } = renderHook(() => useMeasure('#test')); 23 | expect(result.current[0]).toEqual({ 24 | width: undefined, 25 | height: undefined, 26 | top: undefined, 27 | right: undefined, 28 | bottom: undefined, 29 | left: undefined, 30 | x: undefined, 31 | y: undefined 32 | }); 33 | }); 34 | 35 | it('should return correct measure value after resize', () => { 36 | mockResizeObserver({ width: 100, height: 200, top: 300, right: 400, bottom: 500, left: 600, x: 10, y: 20 }); 37 | 38 | render(
); 39 | const { result } = renderHook(() => useMeasure('.test')); 40 | 41 | const measure = result.current[0]; 42 | expect(measure.width).toEqual(100); 43 | expect(measure.height).toEqual(200); 44 | expect(measure.top).toEqual(300); 45 | expect(measure.right).toEqual(400); 46 | expect(measure.bottom).toEqual(500); 47 | expect(measure.left).toEqual(600); 48 | expect(measure.x).toEqual(10); 49 | expect(measure.y).toEqual(20); 50 | }); 51 | 52 | it('should return correct measure value after resize with ref', () => { 53 | mockResizeObserver({ width: 100, height: 200, top: 300, right: 400, bottom: 500, left: 600, x: 10, y: 20 }); 54 | 55 | render(
useRef
); 56 | const ref = { current: screen.getAllByText('useRef')[0] }; 57 | const { result } = renderHook(() => useMeasure(ref)); 58 | 59 | const measure = result.current[0]; 60 | expect(measure.width).toEqual(100); 61 | expect(measure.height).toEqual(200); 62 | expect(measure.top).toEqual(300); 63 | expect(measure.right).toEqual(400); 64 | expect(measure.bottom).toEqual(500); 65 | expect(measure.left).toEqual(600); 66 | expect(measure.x).toEqual(10); 67 | expect(measure.y).toEqual(20); 68 | }); 69 | }); -------------------------------------------------------------------------------- /src/useMeasure/demo/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | import { useMeasure } from '../index'; 3 | 4 | function Demo() { 5 | const ref = useRef(null); 6 | const [size] = useMeasure(ref); 7 | 8 | return ( 9 |
10 | {size.width},{size.height},{size.top},{size.right},{size.bottom},{size.left},{size.x},{size.y} 11 | 12 |
13 | ) 14 | } 15 | 16 | export default Demo; -------------------------------------------------------------------------------- /src/useMeasure/index.tsx: -------------------------------------------------------------------------------- 1 | import { isType } from '../define'; 2 | import { useState, useEffect, useCallback, RefObject } from 'react'; 3 | import { ReturnValue } from '../define'; 4 | 5 | // Define type for measurements of an element 6 | export type Measure = { 7 | width?: number; 8 | height?: number; 9 | top?: number, 10 | right?: number, 11 | bottom?: number, 12 | left?: number, 13 | x?: number, 14 | y?: number 15 | }; 16 | 17 | // Define the types of arguments that can be passed to the hook 18 | type ElementArg = string | RefObject; 19 | 20 | // Define an interface for Actions, which are not used in this hook 21 | interface Action { }; 22 | 23 | const useMeasure = (element: ElementArg): ReturnValue => { 24 | // Initialize state with the Measure object 25 | const [measure, setMeasure] = useState({ 26 | width: undefined, 27 | height: undefined, 28 | top: undefined, 29 | right: undefined, 30 | bottom: undefined, 31 | left: undefined, 32 | x: undefined, 33 | y: undefined 34 | }); 35 | 36 | // Define a callback function to get the element 37 | const getElement = useCallback(() => { 38 | if (isType(element, () => typeof element === 'string')) { 39 | // If the element argument is a string, get the element using the querySelector method 40 | return document.querySelector(element); 41 | } else { 42 | // If the element argument is a ref, get the element using the current property 43 | return element.current; 44 | } 45 | }, [element]) 46 | 47 | // Attach a ResizeObserver to the element and update state with the new measurements 48 | useEffect(() => { 49 | const target = getElement(); 50 | 51 | const resizeObserver = new ResizeObserver(([entry]) => { 52 | setMeasure({ 53 | width: entry.contentRect.width, 54 | height: entry.contentRect.height, 55 | top: entry.contentRect.top, 56 | right: entry.contentRect.right, 57 | bottom: entry.contentRect.bottom, 58 | left: entry.contentRect.left, 59 | x: entry.contentRect.x, 60 | y: entry.contentRect.y 61 | }); 62 | }); 63 | 64 | if (target) { 65 | resizeObserver.observe(target); 66 | } 67 | 68 | // Detach the ResizeObserver when the component is unmounted 69 | return () => { 70 | resizeObserver.disconnect(); 71 | }; 72 | }, [element]); 73 | 74 | // Return the current measurements and an empty object, since no actions are performed in this hook 75 | return [measure, {}]; 76 | }; 77 | 78 | export { useMeasure }; -------------------------------------------------------------------------------- /src/useOutClick/__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { render, renderHook, fireEvent } from '@testing-library/react'; 2 | import { useOutClick } from '../'; 3 | 4 | describe('Test useOutClick', () => { 5 | it('handler is called when clicking outside of the element', () => { 6 | const handler = jest.fn(); 7 | const TestComponent = () => { 8 | const [ref] = useOutClick(handler); 9 | return ( 10 |
11 |
Inside element
12 | Outside element 13 |
14 | ); 15 | }; 16 | 17 | const { getByText } = render(); 18 | fireEvent.mouseDown(getByText('Outside element')); 19 | 20 | expect(handler).toHaveBeenCalledTimes(1); 21 | }); 22 | 23 | it('handler is not called when clicking inside of the element', () => { 24 | const handler = jest.fn(); 25 | const TestComponent = () => { 26 | const [ref] = useOutClick(handler); 27 | return ( 28 |
29 |
Inside element
30 | Outside element 31 |
32 | ); 33 | }; 34 | 35 | const { getByText } = render(); 36 | fireEvent.mouseDown(getByText('Inside element')); 37 | 38 | expect(handler).not.toHaveBeenCalled(); 39 | }); 40 | }); -------------------------------------------------------------------------------- /src/useOutClick/demo/index.tsx: -------------------------------------------------------------------------------- 1 | import { useOutClick } from '../'; 2 | function Demo() { 3 | const [ref] = useOutClick(() => { 4 | alert('click outter'); 5 | }); 6 | return
7 |
inner
8 | outter 9 |
10 | } 11 | 12 | export default Demo; -------------------------------------------------------------------------------- /src/useOutClick/index.tsx: -------------------------------------------------------------------------------- 1 | import { useRef, RefObject, useEffect } from 'react'; 2 | import { ReturnValue } from '../define'; 3 | 4 | interface Action { } 5 | 6 | function useOutClick(handler: () => void): ReturnValue, Action> { 7 | const ref = useRef(null); 8 | 9 | useEffect(() => { 10 | const handleClickOutside = (e: MouseEvent) => { 11 | if (ref.current && !ref.current.contains(e.target as HTMLElement)) { 12 | handler(); 13 | } 14 | } 15 | window.addEventListener('mousedown', handleClickOutside); 16 | 17 | 18 | return () => { 19 | window.removeEventListener('mousedown', handleClickOutside); 20 | } 21 | }, [handler]); 22 | 23 | return [ref, {}]; 24 | } 25 | 26 | export { 27 | useOutClick 28 | } -------------------------------------------------------------------------------- /src/usePortal/__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; 2 | import { render, renderHook, screen } from '@testing-library/react'; 3 | import { usePortal } from '../index'; 4 | import { act } from 'react-dom/test-utils'; 5 | 6 | describe('Test usePortal', () => { 7 | it('Does not render content if container does not exist', () => { 8 | const fn = jest.fn(); 9 | fn.mockReturnValue(
global component
); 10 | 11 | const { result } = renderHook((props) => usePortal(props.callback, props.dom), { 12 | initialProps: { 13 | callback: fn, 14 | dom: null 15 | } 16 | }); 17 | 18 | act(() => { 19 | const content = result.current[1].render(); 20 | content && render(content); 21 | }); 22 | 23 | expect(document.body.children).toHaveLength(1); 24 | }); 25 | 26 | it('Renders global component to body instead of root', () => { 27 | const fn = jest.fn(); 28 | fn.mockReturnValue(
global component
); 29 | 30 | const { result } = renderHook((props) => usePortal(props.callback, props.dom), { 31 | initialProps: { 32 | callback: fn, 33 | dom: document.body 34 | } 35 | }); 36 | 37 | act(() => { 38 | const content = result.current[1].render(); 39 | content && render(content); 40 | }); 41 | 42 | const com = screen.getByText('global component'); 43 | expect(com.parentElement).toEqual(document.body); 44 | expect(fn).toHaveBeenCalledTimes(1); 45 | }); 46 | }); -------------------------------------------------------------------------------- /src/usePortal/demo/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useCallback } from 'react'; 2 | import { usePortal } from '../index'; 3 | /** 4 | * @input 5 | * import { usePortal } from 'microhook'; 6 | */ 7 | 8 | function Node(props: { visible: boolean }) { 9 | const { visible } = props; 10 | console.log('rerender') 11 | return
12 | show: {String(visible)} 13 |
14 | } 15 | const MemoNode = React.memo(Node); 16 | 17 | function Demo() { 18 | const [visible, setVisible] = useState(false); 19 | const [loading, setLoading] = useState(false); 20 | 21 | /** To avoid re-render, please use useCallback and React.memo */ 22 | const createExample = useCallback(() => , [visible]); 23 | const [, { render }] = usePortal( 24 | createExample, 25 | document.body 26 | ); 27 | return ( 28 |
29 | {String(visible)}\{String(loading)} 30 | {render() || null} 31 |
32 | 35 | 38 |
39 |
40 | ) 41 | } 42 | 43 | export { 44 | Demo 45 | } -------------------------------------------------------------------------------- /src/usePortal/index.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | import { ReturnValue } from '../define'; 4 | 5 | interface Action { 6 | render: () => React.ReactPortal | null; 7 | } 8 | 9 | /** 10 | * @param callback Make sure to using with useCallback and React.memo, see `demo` for details. 11 | * @param container html element, like `document.body`. 12 | * @returns Render function: () => JSX.Element | null 13 | */ 14 | function usePortal(callback: () => React.ReactNode, container: HTMLElement | null): ReturnValue { 15 | const content = useMemo(() => callback(), [callback]); 16 | 17 | const render = useCallback( 18 | () => { 19 | if (!container) return null; 20 | const portal = createPortal(content, container); 21 | return portal; 22 | }, 23 | [content, container] 24 | ); 25 | 26 | return [, { render }]; 27 | } 28 | 29 | export { 30 | usePortal 31 | }; -------------------------------------------------------------------------------- /src/usePrefetch/__tests__/createResource.spec.ts: -------------------------------------------------------------------------------- 1 | import { createResource, isHTMLLinkElement } from '../createResource'; 2 | 3 | describe('createResource', () => { 4 | test('should create an image resource with the correct url', () => { 5 | const url = 'https://example.com/image.jpg'; 6 | const resource = createResource(url, 'image') as HTMLImageElement; 7 | expect(resource.nodeName).toBe('IMG'); 8 | expect(resource.src).toBe(url); 9 | }); 10 | 11 | test('should create an image resource with crossOrigin if provided', () => { 12 | const url = 'https://example.com/image.jpg'; 13 | const crossOrigin = 'anonymous'; 14 | const resource = createResource(url, 'image', crossOrigin) as HTMLImageElement; 15 | expect(resource.crossOrigin).toBe(crossOrigin); 16 | }); 17 | 18 | test('should create a script resource with the correct url', () => { 19 | const url = 'https://example.com/script.js'; 20 | const resource = createResource(url, 'script') as HTMLScriptElement; 21 | expect(resource.nodeName).toBe('SCRIPT'); 22 | expect(resource.src).toBe(url); 23 | }); 24 | 25 | test('should create a script resource with crossOrigin if provided', () => { 26 | const url = 'https://example.com/script.js'; 27 | const crossOrigin = 'anonymous'; 28 | const resource = createResource(url, 'script', crossOrigin) as HTMLScriptElement; 29 | expect(resource.crossOrigin).toBe(crossOrigin); 30 | }); 31 | 32 | test('should create a link resource with the correct url', () => { 33 | const url = 'https://example.com/style.css'; 34 | const resource = createResource(url, 'link') as HTMLLinkElement; 35 | expect(resource.nodeName).toBe('LINK'); 36 | expect(resource.href).toBe(url); 37 | expect(resource.rel).toBe('stylesheet'); 38 | }); 39 | 40 | test('should create a link resource with crossOrigin if provided', () => { 41 | const url = 'https://example.com/style.css'; 42 | const crossOrigin = 'use-credentials'; 43 | const resource = createResource(url, 'link', crossOrigin) as HTMLLinkElement; 44 | expect(resource.crossOrigin).toBe(crossOrigin); 45 | }); 46 | 47 | test('should create an audio resource with the correct url', () => { 48 | const url = 'https://example.com/audio.mp3'; 49 | const resource = createResource(url, 'audio') as HTMLAudioElement; 50 | expect(resource.nodeName).toBe('AUDIO'); 51 | expect(resource.src).toBe(url); 52 | }); 53 | 54 | test('should create an audio resource with crossOrigin if provided', () => { 55 | const url = 'https://example.com/audio.mp3'; 56 | const crossOrigin = 'anonymous'; 57 | const resource = createResource(url, 'audio', crossOrigin) as HTMLAudioElement; 58 | expect(resource.crossOrigin).toBe(crossOrigin); 59 | }); 60 | 61 | test('should create a video resource with the correct url', () => { 62 | const url = 'https://example.com/video.mp4'; 63 | const resource = createResource(url, 'video') as HTMLVideoElement; 64 | expect(resource.nodeName).toBe('VIDEO'); 65 | expect(resource.src).toBe(url); 66 | }); 67 | 68 | test('should create a video resource with crossOrigin if provided', () => { 69 | const url = 'https://example.com/video.mp4'; 70 | const crossOrigin = 'use-credentials'; 71 | const resource = createResource(url, 'video', crossOrigin) as HTMLVideoElement; 72 | expect(resource.crossOrigin).toBe(crossOrigin); 73 | }); 74 | 75 | test('should throw an error for an invalid resource type', () => { 76 | const url = 'https://example.com/file'; 77 | expect(() => createResource(url, 'invalid' as any)).toThrow('Invalid resource type'); 78 | }); 79 | }); 80 | 81 | describe('isHTMLLinkElement', () => { 82 | it('should return true for a HTMLLinkElement', () => { 83 | const link = document.createElement('link'); 84 | const result = isHTMLLinkElement(link); 85 | expect(result).toBe(true); 86 | }); 87 | 88 | it('should return false for a HTMLImageElement', () => { 89 | const img = new Image(); 90 | const result = isHTMLLinkElement(img); 91 | expect(result).toBe(false); 92 | }); 93 | 94 | it('should return false for a HTMLScriptElement', () => { 95 | const script = document.createElement('script'); 96 | const result = isHTMLLinkElement(script); 97 | expect(result).toBe(false); 98 | }); 99 | 100 | it('should return false for a HTMLAudioElement', () => { 101 | const audio = new Audio(); 102 | const result = isHTMLLinkElement(audio); 103 | expect(result).toBe(false); 104 | }); 105 | 106 | it('should return false for a HTMLVideoElement', () => { 107 | const video = document.createElement('video'); 108 | const result = isHTMLLinkElement(video); 109 | expect(result).toBe(false); 110 | }); 111 | }); 112 | 113 | 114 | 115 | 116 | 117 | -------------------------------------------------------------------------------- /src/usePrefetch/__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook, act } from '@testing-library/react'; 2 | import { usePrefetch } from '../index'; 3 | 4 | describe('Test usePrefetch', () => { 5 | it('should prefetch resources when the component mounts', async () => { 6 | const urls = ['https://example.com/image1.jpg', 'https://example.com/image2.jpg']; 7 | const prefetchSpy = jest.spyOn(window.HTMLImageElement.prototype, 'src', 'set'); 8 | 9 | renderHook(() => usePrefetch(urls)); 10 | 11 | await act(async () => { 12 | await new Promise(resolve => setTimeout(resolve, 100)); 13 | }); 14 | 15 | expect(prefetchSpy).toHaveBeenCalledTimes(2); 16 | expect(prefetchSpy).toHaveBeenCalledWith(urls[0]); 17 | expect(prefetchSpy).toHaveBeenCalledWith(urls[1]); 18 | 19 | prefetchSpy.mockRestore(); 20 | }); 21 | }); 22 | 23 | describe('Test usePrefetch with mock Image', () => { 24 | let createImageSpy: jest.SpyInstance; 25 | let addEventListener: jest.Mock; 26 | let removeEventListener: jest.Mock; 27 | 28 | beforeEach(() => { 29 | addEventListener = jest.fn(); 30 | removeEventListener = jest.fn(); 31 | createImageSpy = jest.spyOn(global, 'Image').mockImplementation(() => ({ 32 | addEventListener, 33 | removeEventListener, 34 | src: '', 35 | crossOrigin: '', 36 | }) as any); 37 | }); 38 | 39 | afterEach(() => { 40 | createImageSpy.mockRestore(); 41 | addEventListener.mockClear(); 42 | removeEventListener.mockClear() 43 | }); 44 | 45 | it('should load image correctly', () => { 46 | const urls = ['https://example.com/image1.jpg', 'https://example.com/image2.jpg']; 47 | renderHook(() => usePrefetch(urls)); 48 | addEventListener.mock.calls[2][1](); 49 | expect(addEventListener).toHaveBeenCalledTimes(4); 50 | }); 51 | 52 | it('should load image error', () => { 53 | const urls = ['https://example.com/image1.jpg', 'https://example.com/image2.jpg']; 54 | renderHook(() => usePrefetch(urls)); 55 | addEventListener.mock.calls[3][1](); 56 | addEventListener.mock.calls[3][1](); 57 | addEventListener.mock.calls[3][1](); 58 | expect(addEventListener).toHaveBeenCalledTimes(4); 59 | }); 60 | }); 61 | 62 | describe('Test usePrefetch hit cache', () => { 63 | let createImageSpy: jest.SpyInstance; 64 | let addEventListener: jest.Mock; 65 | let removeEventListener: jest.Mock; 66 | 67 | beforeEach(() => { 68 | addEventListener = jest.fn(); 69 | removeEventListener = jest.fn(); 70 | createImageSpy = jest.spyOn(global, 'Image').mockImplementation(() => ({ 71 | addEventListener, 72 | removeEventListener, 73 | src: '', 74 | crossOrigin: '', 75 | }) as any); 76 | }); 77 | 78 | it('should hit cache', () => { 79 | const urls = ['https://example.com/image1.jpg', 'https://example.com/image1.jpg']; 80 | const { rerender } = renderHook(() => usePrefetch(urls)); 81 | addEventListener.mock.calls[2][1](); 82 | rerender(); 83 | expect(addEventListener).toHaveBeenCalledTimes(4); 84 | }); 85 | 86 | afterEach(() => { 87 | createImageSpy.mockRestore(); 88 | addEventListener.mockClear(); 89 | removeEventListener.mockClear() 90 | }); 91 | }); 92 | 93 | describe('Test usePrefetch link', () => { 94 | it('should load link', () => { 95 | const addEventListenerSpy = jest.spyOn(HTMLLinkElement.prototype, 'addEventListener') as any; 96 | 97 | const url = 'https://example.com/image1.jpg'; 98 | renderHook(() => usePrefetch(url, { type: 'link' })); 99 | 100 | addEventListenerSpy.mock.calls[1][1]?.(); 101 | addEventListenerSpy.mock.calls[1][1]?.(); 102 | addEventListenerSpy.mock.calls[1][1]?.(); 103 | addEventListenerSpy.mock.calls[0][1]?.(); 104 | addEventListenerSpy.mock.calls[1][1]?.(); 105 | 106 | expect(addEventListenerSpy).toHaveBeenCalledTimes(2); 107 | 108 | }); 109 | }); -------------------------------------------------------------------------------- /src/usePrefetch/createResource.ts: -------------------------------------------------------------------------------- 1 | export type ResourceType = 'image' | 'script' | 'link' | 'audio' | 'video'; 2 | 3 | export type Resource = HTMLImageElement | HTMLScriptElement | HTMLLinkElement | HTMLAudioElement | HTMLVideoElement; 4 | 5 | function isHTMLLinkElement(element: Resource): element is HTMLLinkElement { 6 | return element instanceof HTMLLinkElement; 7 | } 8 | 9 | function createResource(url: string, type: ResourceType, crossOrigin?: 'anonymous' | 'use-credentials'): Resource { 10 | switch (type) { 11 | case 'image': { 12 | const img = new Image(); 13 | img.src = url; 14 | if (crossOrigin) { 15 | img.crossOrigin = crossOrigin; 16 | } 17 | return img; 18 | } 19 | case 'script': { 20 | const script = document.createElement('script'); 21 | script.src = url; 22 | if (crossOrigin) { 23 | script.crossOrigin = crossOrigin; 24 | } 25 | document.body.append(script); 26 | return script; 27 | } 28 | case 'link': { 29 | const link = document.createElement('link'); 30 | link.href = url; 31 | link.rel = 'stylesheet'; 32 | if (crossOrigin) { 33 | link.crossOrigin = crossOrigin; 34 | } 35 | document.head.append(link); 36 | return link; 37 | } 38 | case 'audio': { 39 | const audio = new Audio(); 40 | audio.src = url; 41 | if (crossOrigin) { 42 | audio.crossOrigin = crossOrigin; 43 | } 44 | return audio; 45 | } 46 | case 'video': { 47 | const video = document.createElement('video'); 48 | video.src = url; 49 | if (crossOrigin) { 50 | video.crossOrigin = crossOrigin; 51 | } 52 | return video; 53 | } 54 | default: 55 | throw new Error(`Invalid resource type: ${type}`); 56 | } 57 | } 58 | 59 | export { 60 | createResource, 61 | isHTMLLinkElement 62 | } -------------------------------------------------------------------------------- /src/usePrefetch/demo/index.tsx: -------------------------------------------------------------------------------- 1 | import { usePrefetch } from '../index'; 2 | 3 | /** 4 | * @input 5 | * import { usePrefetch } from 'microhook'; 6 | */ 7 | 8 | const images = [ 9 | 'https://via.placeholder.com/150', 10 | 'https://via.placeholder.com/150/0000FF/808080', 11 | 'https://via.placeholder.com/150/FF0000/FFFFFF', 12 | ]; 13 | 14 | function App() { 15 | usePrefetch(images, { type: 'image' }); 16 | 17 | return ( 18 |
19 |

Image Gallery

20 | {images.map((image, index) => ( 21 | {`${index}`} 22 | ))} 23 |
24 | ); 25 | } 26 | 27 | export default App; -------------------------------------------------------------------------------- /src/usePrefetch/index.tsx: -------------------------------------------------------------------------------- 1 | // Import dependencies 2 | import type { ResourceType, Resource } from './createResource'; 3 | import { useEffect, useRef, useCallback } from 'react'; 4 | import { createResource, isHTMLLinkElement } from './createResource'; 5 | import { ReturnValue } from '../define'; 6 | 7 | // Define options type 8 | type Options = { 9 | type?: ResourceType; 10 | crossOrigin?: 'anonymous' | 'use-credentials'; 11 | maxRetryTimes?: number; 12 | maxConcurrent?: number; 13 | onLoad?: (resource: Resource) => void; 14 | }; 15 | 16 | // Define resource item type 17 | type ResourceItem = { 18 | url: string; 19 | retryTimes: number; 20 | element: Resource; 21 | }; 22 | 23 | // Define action interface 24 | interface Action { } 25 | 26 | // Define the usePrefetch hook 27 | function usePrefetch(urls: string[] | string, options: Options = {}): ReturnValue, Action> { 28 | // Destructure options 29 | const { 30 | type = 'image', 31 | crossOrigin, 32 | maxRetryTimes = 3, 33 | onLoad = () => { }, 34 | } = options; 35 | 36 | // Create refs for cache and queue 37 | const cacheRef = useRef>({}); 38 | const queueRef = useRef([]); 39 | 40 | // Define the handleLoad function 41 | const handleLoad = useCallback((resource: Resource) => { 42 | const item = queueRef.current.find((item) => item.element === resource); 43 | if (item) { 44 | // Add the loaded resource to the cache 45 | cacheRef.current[item.url] = resource; 46 | // Remove the item from the queue 47 | queueRef.current = queueRef.current.filter((queue) => queue !== item); 48 | // Call the onLoad callback 49 | onLoad(resource); 50 | } 51 | }, [onLoad]); 52 | 53 | // Define the handleError function 54 | const handleError = useCallback((resource: Resource) => { 55 | const item = queueRef.current.find((item) => item.element === resource); 56 | if (item) { 57 | // Increment the retry count 58 | item.retryTimes += 1; 59 | // Retry loading the resource if retry count is less than maxRetryTimes 60 | if (item.retryTimes < maxRetryTimes) { 61 | if (isHTMLLinkElement(item.element)) { 62 | item.element.href = `${item.url}?retry=${item.retryTimes}`; 63 | } else { 64 | item.element.src = `${item.url}?retry=${item.retryTimes}`; 65 | } 66 | } else { 67 | // Remove the item from the queue if retry count is equal to maxRetryTimes 68 | queueRef.current = queueRef.current.filter(queue => queue !== item); 69 | } 70 | } 71 | }, [maxRetryTimes]); 72 | 73 | // Add resources to the queue and listen for load and error events 74 | useEffect(() => { 75 | const urlsArr = Array.isArray(urls) ? urls : [urls]; 76 | const handles = urlsArr.map((url) => { 77 | if (cacheRef.current[url]) { 78 | return {} 79 | } 80 | 81 | const resource = createResource(url, type, crossOrigin); 82 | 83 | const handleResourceLoad = () => handleLoad(resource); 84 | const handleResourceError = () => handleError(resource); 85 | 86 | resource.addEventListener('load', handleResourceLoad); 87 | resource.addEventListener('error', handleResourceError); 88 | 89 | const item: ResourceItem = { url, retryTimes: 0, element: resource }; 90 | queueRef.current = [...queueRef.current, item]; 91 | return { 92 | element: resource, 93 | handleResourceLoad, 94 | handleResourceError 95 | } 96 | }); 97 | 98 | // Remove event listeners when component unmounts 99 | return () => { 100 | handles.forEach(handle => { 101 | handle?.element?.removeEventListener('load', handle.handleResourceLoad); 102 | handle?.element?.removeEventListener('error', handle.handleResourceError); 103 | }) 104 | } 105 | 106 | }, [urls, crossOrigin, type, handleError, handleLoad]); 107 | 108 | return [cacheRef.current, {}] 109 | } 110 | 111 | export { 112 | usePrefetch 113 | } 114 | 115 | 116 | -------------------------------------------------------------------------------- /src/useRestHeight/__tests__/index.spec.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { render, renderHook, screen } from "@testing-library/react"; 3 | 4 | import { useRestHeight } from '../index'; 5 | 6 | describe('Test useRestHeight', () => { 7 | beforeAll(() => { 8 | window.ResizeObserver = 9 | window.ResizeObserver || 10 | function () { 11 | return { 12 | observe: jest.fn(), 13 | disconnect: jest.fn(), 14 | unobserve: jest.fn() 15 | } 16 | }; 17 | }); 18 | 19 | it('should have a height of 0 when parent container does not exist', () => { 20 | const { result } = renderHook( 21 | props => useRestHeight(props.parent), { 22 | initialProps: { 23 | parent: undefined 24 | } 25 | } 26 | ); 27 | const [restHeight] = result.current; 28 | expect(restHeight).toEqual(0); 29 | }); 30 | 31 | it('should have a height of 200 when parent container has a height of 200 and no child elements', () => { 32 | const getBoundingClientRect = jest.fn().mockReturnValue({ height: 200 }); 33 | HTMLElement.prototype.getBoundingClientRect = getBoundingClientRect; 34 | 35 | render(
); 36 | const { result } = renderHook( 37 | props => useRestHeight(props.parent), 38 | { 39 | initialProps: { 40 | parent: '.parent' 41 | } 42 | } 43 | ); 44 | 45 | expect(result.current[0]).toEqual(200); 46 | }); 47 | 48 | it('should have a height of 140 when parent container has a height of 200 and two child elements have heights of 20 and 40 respectively', () => { 49 | const getBoundingClientRect = jest.fn() 50 | .mockReturnValue({ height: 200 }) 51 | .mockReturnValueOnce({ height: 200 }) 52 | .mockReturnValueOnce({ height: 20 }) 53 | .mockReturnValueOnce({ height: 40 }) 54 | .mockReturnValueOnce({ height: 200 }) 55 | .mockReturnValueOnce({ height: 20 }) 56 | .mockReturnValueOnce({ height: 40 }); 57 | HTMLElement.prototype.getBoundingClientRect = getBoundingClientRect; 58 | 59 | render( 60 |
61 |
62 |
63 |
64 | ); 65 | const { result } = renderHook( 66 | props => useRestHeight(props.parent, props.children), 67 | { 68 | initialProps: { 69 | parent: '.parent', 70 | children: ['.first', '.second'] 71 | } 72 | } 73 | ); 74 | 75 | expect(result.current[0]).toEqual(140); 76 | }); 77 | 78 | it('should have a height of 140 when parent container has a height of 200 and two child elements have heights of 20 and 40 respectively and elements are accessed by ID', () => { 79 | const getBoundingClientRect = jest.fn() 80 | .mockReturnValue({ height: 200 }) 81 | .mockReturnValueOnce({ height: 200 }) 82 | .mockReturnValueOnce({ height: 20 }) 83 | .mockReturnValueOnce({ height: 40 }) 84 | .mockReturnValueOnce({ height: 200 }) 85 | .mockReturnValueOnce({ height: 20 }) 86 | .mockReturnValueOnce({ height: 40 }) 87 | HTMLElement.prototype.getBoundingClientRect = getBoundingClientRect; 88 | 89 | render( 90 |
91 |
92 |
93 |
94 | ); 95 | const { result } = renderHook( 96 | props => useRestHeight(props.parent, props.children), 97 | { 98 | initialProps: { 99 | parent: '.parent', 100 | children: ['.first', '#second', '#third'] 101 | } 102 | } 103 | ); 104 | 105 | expect(result.current[0]).toEqual(140); 106 | }); 107 | 108 | it('when parent container has a height of 200, two child elements with heights of 20 and 40 respectively, custom offsets of 5 and 10, and a height of 125', () => { 109 | const getBoundingClientRect = jest.fn() 110 | .mockReturnValue({ height: 200 }) 111 | .mockReturnValueOnce({ height: 200 }) 112 | .mockReturnValueOnce({ height: 20 }) 113 | .mockReturnValueOnce({ height: 40 }) 114 | .mockReturnValueOnce({ height: 200 }) 115 | .mockReturnValueOnce({ height: 20 }) 116 | .mockReturnValueOnce({ height: 40 }); 117 | HTMLElement.prototype.getBoundingClientRect = getBoundingClientRect; 118 | 119 | render( 120 |
121 |
122 |
123 |
124 | ); 125 | const { result } = renderHook( 126 | props => useRestHeight(props.parent, props.children, props.offsets), 127 | { 128 | initialProps: { 129 | parent: '.parent', 130 | children: ['.first', '.second'], 131 | offsets: [5, 10] 132 | } 133 | } 134 | ); 135 | 136 | expect(result.current[0]).toEqual(125); 137 | }); 138 | 139 | it('simulates useRef', () => { 140 | const getBoundingClientRect = jest.fn().mockReturnValue({ height: 200 }); 141 | HTMLElement.prototype.getBoundingClientRect = getBoundingClientRect; 142 | 143 | render(
useRef
); 144 | const ref = { current: screen.getAllByText('useRef')[0] }; 145 | const { result } = renderHook( 146 | props => useRestHeight(props.parent), 147 | { 148 | initialProps: { 149 | parent: ref 150 | } 151 | } 152 | ); 153 | 154 | expect(result.current[0]).toEqual(200); 155 | }); 156 | }); -------------------------------------------------------------------------------- /src/useRestHeight/demo/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | import { useRestHeight } from '../index'; 3 | 4 | /** 5 | * @input 6 | * import { useRestHeight } from 'microhook'; 7 | */ 8 | 9 | function Demo() { 10 | const parentRef = useRef(null); 11 | const child1Ref = useRef(null); 12 | const child2Ref = useRef(null); 13 | 14 | const [resetHeight] = useRestHeight( 15 | parentRef, 16 | [child1Ref, '.child2'], 17 | [1, 2, 3, 4] 18 | ); 19 | 20 | return ( 21 |
22 |
23 |
24 | 25 |
26 |
27 | {resetHeight} 28 |
29 |
30 | ); 31 | } 32 | 33 | export { 34 | Demo 35 | } -------------------------------------------------------------------------------- /src/useRestHeight/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * @author Ruimve 3 | */ 4 | 5 | import { useState, useEffect, RefObject } from 'react'; 6 | import { ReturnValue } from '../define'; 7 | 8 | type ElementArg = string | RefObject; 9 | 10 | interface Action { 11 | recalculateHeight: () => void; 12 | } 13 | 14 | /** 15 | * Gets the HTMLElement from a string or RefObject. 16 | * 17 | * @param element - The string selector or RefObject to get the HTMLElement from. 18 | * @returns The HTMLElement or null. 19 | */ 20 | function getElement(element: ElementArg | null | undefined): HTMLElement | null { 21 | if (element instanceof Object && element.current instanceof HTMLElement) { 22 | return element.current; 23 | } else if (typeof element === 'string') { 24 | return document.querySelector(element); 25 | } else { 26 | return null; 27 | } 28 | } 29 | 30 | /** 31 | * Returns an array with the remaining height of the parent element 32 | * after subtracting the height of its child elements and any specified offsets. 33 | * 34 | * @param parent - The parent element or its RefObject. 35 | * @param children - The child elements or their RefObjects. 36 | * @param offsets - The offsets to subtract from the parent element's height. 37 | * @returns An array with the remaining height and a recalculateHeight function. 38 | */ 39 | function useRestHeight( 40 | parent?: ElementArg, 41 | children?: ElementArg[], 42 | offsets: number[] = [], 43 | ): ReturnValue { 44 | const [restHeight, setRestHeight] = useState(0); 45 | 46 | /** 47 | * Calculates the remaining height of the parent element and sets the restHeight state. 48 | */ 49 | const updateHeight = () => { 50 | const parentElement = getElement(parent); 51 | const childElements = 52 | children?.flatMap((child) => 53 | Array.from(getElement(child) ? [getElement(child)!] : []), 54 | ) ?? []; 55 | 56 | if (!parentElement) { 57 | return; 58 | } 59 | 60 | const parentHeight = parentElement.getBoundingClientRect().height; 61 | const childHeight = childElements.reduce( 62 | (totalHeight, childRef) => totalHeight + childRef.getBoundingClientRect().height, 63 | 0, 64 | ); 65 | 66 | const totalOffset = offsets.reduce((acc, curr) => acc + curr, 0); 67 | setRestHeight(parentHeight - childHeight - totalOffset); 68 | } 69 | 70 | /** 71 | * Sets up ResizeObservers on the parent and child elements to update the restHeight state. 72 | * Returns a cleanup function to disconnect the observers. 73 | */ 74 | useEffect(() => { 75 | updateHeight(); 76 | 77 | const parentElement = getElement(parent); 78 | const childElements = 79 | children?.flatMap((child) => 80 | Array.from(getElement(child) ? [getElement(child)!] : []), 81 | ) ?? []; 82 | 83 | if (parentElement) { 84 | const parentObserver = new ResizeObserver(updateHeight); 85 | parentObserver.observe(parentElement); 86 | 87 | const childObservers = childElements.map((child) => { 88 | const observer = new ResizeObserver(updateHeight); 89 | observer.observe(child); 90 | return observer; 91 | }); 92 | 93 | return () => { 94 | parentObserver.disconnect(); 95 | childObservers.forEach((observer) => observer.disconnect()); 96 | }; 97 | } 98 | }, [parent, children, offsets]); 99 | 100 | return [restHeight, { recalculateHeight: updateHeight }]; 101 | } 102 | 103 | export { useRestHeight }; -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Projects */ 6 | // "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */ 7 | // "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */ 8 | // "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */ 9 | // "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */ 10 | // "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */ 11 | // "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */ 12 | 13 | /* Language and Environment */ 14 | "target": "ES2015", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 15 | // "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */ 16 | "jsx": "react-jsx", /* Specify what JSX code is generated. */ 17 | // "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */ 18 | // "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */ 19 | // "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */ 20 | // "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */ 21 | // "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */ 22 | // "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */ 23 | // "noLib": true, /* Disable including any library files, including the default lib.d.ts. */ 24 | // "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */ 25 | // "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */ 26 | 27 | /* Modules */ 28 | "module": "ES2015", /* Specify what module code is generated. */ 29 | // "rootDir": "./", /* Specify the root folder within your source files. */ 30 | "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */ 31 | // "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */ 32 | // "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */ 33 | // "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */ 34 | // "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */ 35 | // "types": [], /* Specify type package names to be included without being referenced in a source file. */ 36 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 37 | // "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */ 38 | // "resolveJsonModule": true, /* Enable importing .json files. */ 39 | // "noResolve": true, /* Disallow 'import's, 'require's or ''s from expanding the number of files TypeScript should add to a project. */ 40 | 41 | /* JavaScript Support */ 42 | // "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */ 43 | // "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */ 44 | // "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */ 45 | 46 | /* Emit */ 47 | "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */ 48 | // "declarationMap": true, /* Create sourcemaps for d.ts files. */ 49 | // "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */ 50 | // "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 51 | // "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */ 52 | "outDir": "./types", /* Specify an output folder for all emitted files. */ 53 | // "removeComments": true, /* Disable emitting comments. */ 54 | // "noEmit": true, /* Disable emitting files from a compilation. */ 55 | // "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */ 56 | // "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */ 57 | // "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */ 58 | // "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */ 59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 60 | // "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */ 61 | // "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */ 62 | // "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */ 63 | // "newLine": "crlf", /* Set the newline character for emitting files. */ 64 | // "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */ 65 | // "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */ 66 | // "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */ 67 | // "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */ 68 | // "declarationDir": "./types", /* Specify the output directory for generated declaration files. */ 69 | // "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */ 70 | 71 | /* Interop Constraints */ 72 | // "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */ 73 | // "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */ 74 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 75 | // "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */ 76 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 77 | 78 | /* Type Checking */ 79 | "strict": true, /* Enable all strict type-checking options. */ 80 | // "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 81 | "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 82 | // "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 83 | // "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */ 84 | // "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */ 85 | // "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */ 86 | // "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */ 87 | // "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 88 | // "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */ 89 | // "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */ 90 | // "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */ 91 | // "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */ 92 | // "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */ 93 | // "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */ 94 | // "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */ 95 | // "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */ 96 | // "allowUnusedLabels": true, /* Disable error reporting for unused labels. */ 97 | // "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */ 98 | 99 | /* Completeness */ 100 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 101 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 102 | }, 103 | "include": [ 104 | "./src/**/*" 105 | ], 106 | "exclude": [ 107 | "**/__tests__/**/*", 108 | "**/demo/**/*" 109 | ] 110 | } 111 | --------------------------------------------------------------------------------