├── .eslintignore ├── .gitignore ├── .prettierrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── eslint.config.js ├── package.json ├── pnpm-lock.yaml ├── react-tiny-popover-short-demo.gif ├── src ├── ArrowContainer.tsx ├── Popover.tsx ├── PopoverPortal.tsx ├── index.d.ts ├── useArrowContainer.ts ├── useElementRef.ts ├── useHandlePrevValues.ts ├── useMemoizedArray.ts ├── usePopover.ts └── util.ts └── tsconfig.json /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | docs/ 3 | node_modules/ 4 | *.d.ts 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | /dist/ 3 | npm-debug.log 4 | .idea 5 | **/.DS_Store 6 | **/yarn-error.log 7 | .vscode 8 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "jsxSingleQuote": true, 4 | "printWidth": 100, 5 | "semi": true, 6 | "singleQuote": true, 7 | "tabWidth": 2, 8 | "trailingComma": "all" 9 | } 10 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## [8.1.6] - 2025-2-2 4 | 5 | ### Fixed 6 | 7 | - Resolved newly introduced module declaration issue from previous release 8 | 9 | ## [8.1.5] - 2025-2-2 10 | 11 | ### Fixed 12 | 13 | - Resolved an issue where popover position sometimes did not occur on window resize 14 | 15 | ## [8.1.4] - 2024-11-20 16 | 17 | ### Fixed 18 | 19 | - Resolved type declaration issue that caused ts errors when rendering Popover component on certain versions of React 20 | 21 | ## [8.1.3] - 2024-11-20 22 | 23 | ### Fixed 24 | 25 | - Popover now immediately positions on open before requesting browser animation 26 | frame for subsequent updates (thanks @cozmo) 27 | 28 | ## [8.1.2] - 2024-9-13 29 | 30 | ### Fixed npm deployment issue 31 | 32 | ## [8.1.1] - 2024-9-13 33 | 34 | ### Fixed 35 | 36 | - Popover now re-renders properly on the following prop changes 37 | - `reposition` 38 | - `positions` 39 | - `boundaryElement` 40 | - `boundaryInset` 41 | - `transform` 42 | - `transformMode` 43 | - `childRect` changes 44 | - `popoverRect` changes 45 | - `padding` 46 | - `align` 47 | 48 | ## [8.1.0] - 2024-9-12 49 | 50 | ### Changed 51 | 52 | - Prior to this change, the portal DOM elements generated when a popover appears 53 | were given the id `react-tiny-popover-container` and `react-tiny-popover-scout` 54 | - From now on, both `react-tiny-popover-container` and `react-tiny-popover-scout` are 55 | now assigned as class names rather than ids. The absence of this functionality 56 | has been an oversight, since multiple popovers can be present in the DOM 57 | simultaneously. This resulted in more than one element having the same id. 58 | 59 | ## [8.0.3] - 2023-10-19 60 | 61 | ### Fixed 62 | 63 | - `align` and `padding` no longer erroneously required as props 64 | 65 | ## [8.0.2] - 2023-10-19 66 | 67 | ### Fixed 68 | 69 | - Removed some debugging statements erroneously published 70 | 71 | ## [8.0.1] - 2023-10-19 72 | 73 | ### Fixed 74 | 75 | - Rolled back `DOMRect` changes as it interferes with SSR, replaced with custom `Rect` interface that mirrors the same API 76 | 77 | ## [8.0.0] - 2023-10-18 78 | 79 | ### Changed 80 | 81 | - `contentLocation` prop has been renamed to `positionTransform`, behaves exactly the same 82 | - `positions` prop now accepts a single string in addition to an array of prioritized strings 83 | - Migrated from deprecated `ClientRect` to `DOMRect` (thanks @jafin) 84 | 85 | ### Added 86 | 87 | - A new `transformMode` prop now accepts string values of `"absolute"` or `"relative"`. `"absolute"` mode is its default, and causes behavior identical to `contentLocation`. The `"relative"` value, however, will cause the provided `top` and `left` values of the transform to merely be summed to the existing `nudgeTop` and `nudgeLeft` values, behaving as a relative positioning system. 88 | 89 | ## [7.2.2] - 2023-02-13 90 | 91 | ### Fixed 92 | 93 | - popover positioning miscalculation issue 94 | 95 | ## [7.2.1] - 2023-02-11 96 | 97 | ### Fixed 98 | 99 | - blurry popover on non-retina displays (thanks @D34THWINGS) 100 | - click-outside support now works across different windows (thanks @dutziworks) 101 | 102 | ## [7.2.0] - 2021-08-24 103 | 104 | ### Added 105 | 106 | - added `clickOutsideCapture` prop to `Popover` 107 | 108 | ## [7.1.0] - 2021-08-24 109 | 110 | ### Added 111 | 112 | - added `violations` property to `PopoverState` 113 | - added `hasViolations` property to `PopoverState` 114 | - React 18 is now an accepted peer dependency 115 | 116 | ### Changed 117 | 118 | - `onClickOutside` now uses event capturing (thanks @davidjgross) 119 | 120 | ### Fixed 121 | 122 | - `usePopover` now returns immediately when popover is not open, fixing an issue where utility and positioning functions sometimes fired even when popover was not open 123 | 124 | ## [7.0.1] - 2021-08-24 125 | 126 | ### Fixed 127 | 128 | - Issue where popovers within a new stacking context would sometimes not render at the correct position 129 | 130 | ## [7.0.0] - 2021-08-24 131 | 132 | ### Added 133 | 134 | - SSR support 135 | - `boundaryElement` prop 136 | 137 | ### Changed 138 | 139 | - Renamed `containerParent` to `parentElement` 140 | 141 | ### Fixed 142 | 143 | - Popovers not rendering at proper location within translated container elements 144 | 145 | ## [6.0.10] - 2021-08-04 146 | 147 | ### Changed 148 | 149 | - Removed `custom` string type from `position` and `align` props, replaced with `undefined` 150 | - `useArrowContainer` does not render an arrow if `position` is `undefined` 151 | 152 | ## [6.0.9] - 2021-08-02 153 | 154 | ### Fixed 155 | 156 | - `ArrowContainer` now handles custom class names properly (thanks KWLEvans) 157 | 158 | ## [6.0.8] - 2021-08-02 159 | 160 | ### Changed 161 | 162 | - Inline source maps 163 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 by Alex Katz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-tiny-popover 2 | 3 | ![demo gif](https://github.com/alexkatz/react-tiny-popover/blob/main/react-tiny-popover-short-demo.gif?raw=true) 4 | 5 | ## [Click here for a full demo](https://alexkatz.github.io/react-tiny-popover-demo/) :+1: 6 | 7 | - [Install](#install) 8 | - [Examples](#examples) 9 | - [Hooks](#hooks) 10 | - [Small breaking change in 8.1](#small-breaking-change-in-81) 11 | - [Migrating to 8.0](#migrating-to-80) 12 | - [Migrating to 5.0](#migrating-to-50) 13 | - [API](#api) 14 | - [Popover](#popover) 15 | - [PopoverState](#popoverstate) 16 | - [ArrowContainer](#arrowcontainer) 17 | 18 | A lightweight, highly customizable, non-intrusive, and Typescript friendly popover react HOC with no other dependencies! 19 | 20 | The component renders its child directly, without wrapping it with anything on the DOM, and in addition renders solely the JSX you provide when shown. It simply grabs the child component's coordinates and provides a robust and non-intrusive way for you to position your own content around the child. Your content will be appended to `document.body` (or an element of your choice) when shown, and removed when hidden. You can use it to generate little popups around input or button elements, menu fly-outs, or in pretty much any situation where you want some content to appear and disappear dynamically around a target. You can also specify your own location for your popover content or hook into the existing positioning process, allowing you to essentially make modal windows and the like, as well! 21 | 22 | `react-tiny-popover` can also guard against container boundaries and reposition itself to prevent any kind of hidden overflow. You can specify a priority of desired positions to fall back to, if you'd like. 23 | 24 | Optionally, you can provide a renderer function for your popover content that injects the popover's current position, in case your content needs to know where it sits in relation to its target. 25 | 26 | Since `react-tiny-popover` tries to be as non-invasive as possible, it will simply render the content you provide with the position and padding from the target that you provide. If you'd like an arrow pointing to the target to appear along with your content and don't feel like building it yourself, you may be interested in wrapping your content with the customizable `ArrowContainer` component, also provided! `ArrowContainer`'s arrow will follow its target dynamically, and handles boundary collisions as well. 27 | 28 | ## Install 29 | 30 | ```shell 31 | npm i react-tiny-popover --save 32 | ``` 33 | 34 | ## Examples 35 | 36 | ```JSX 37 | import { Popover } from 'react-tiny-popover' 38 | 39 | ... 40 | 41 | Hi! I'm popover content.} 45 | > 46 |
setIsPopoverOpen(!isPopoverOpen)}> 47 | Click me! 48 |
49 |
; 50 | ``` 51 | 52 | ```JSX 53 | import { Popover } from 'react-tiny-popover' 54 | 55 | ... 56 | 57 | setIsPopoverOpen(false)} // handle click events outside of the popover/target here! 63 | content={({ position, nudgedLeft, nudgedTop }) => ( // you can also provide a render function that injects some useful stuff! 64 |
65 |
Hi! I'm popover content. Here's my current position: {position}.
66 |
I'm {` ${nudgedLeft} `} pixels beyond my boundary horizontally!
67 |
I'm {` ${nudgedTop} `} pixels beyond my boundary vertically!
68 |
69 | )} 70 | > 71 |
setIsPopoverOpen(!isPopoverOpen)}>Click me!
72 |
; 73 | ``` 74 | 75 | ```JSX 76 | import { useRef } from 'react'; 77 | import { Popover, ArrowContainer } from 'react-tiny-popover' 78 | 79 | const clickMeButtonRef = useRef(); 80 | 81 | setIsPopoverOpen(false)} 86 | ref={clickMeButtonRef} // if you'd like a ref to your popover's child, you can grab one here 87 | content={({ position, childRect, popoverRect }) => ( 88 | 98 |
setIsPopoverOpen(!isPopoverOpen)} 101 | > 102 | Hi! I'm popover content. Here's my position: {position}. 103 |
104 |
105 | )} 106 | > 107 | 110 |
; 111 | ``` 112 | 113 | If you'd like to use a custom React element as `Popover`'s target, you'll have to pass the `ref` that `Popover` provides to an inner DOM element of your component. The best way to accomplish this is with [React's ref forwarding API](https://reactjs.org/docs/forwarding-refs.html). Here's a simple example, using Typescript: 114 | 115 | ```JSX 116 | import React, { useState } from 'react'; 117 | import { Popover } from 'react-tiny-popover'; 118 | 119 | interface CustomComponentProps extends React.ComponentPropsWithoutRef<'div'> { 120 | onClick(): void; 121 | } 122 | 123 | const CustomComponent = React.forwardRef((props, ref) => ( 124 |
125 | {props.children} 126 |
127 | )); 128 | 129 | const App: React.FC = () => { 130 | const [isPopoverOpen, setIsPopoverOpen] = useState(false); 131 | return ( 132 |
133 | hey from popover content
}> 134 | setIsPopoverOpen(!isPopoverOpen)}> 135 | hey from a custom target component 136 | 137 | 138 | 139 | ); 140 | }; 141 | 142 | export default App; 143 | ``` 144 | 145 | ## Hooks 146 | 147 | If you prefer going completely headless (though `react-tiny-popover` is fairly headless as is), you may prefer `usePopover` and `useArrowContainer` instead. 148 | 149 | To create your own custom arrow container, the `useArrowContainer` hook works as so: 150 | 151 | ```JSX 152 | import { useArrowContainer } from 'react-tiny-popover'; 153 | 154 | // ... 155 | 156 | const { arrowContainerStyle, arrowStyle } = useArrowContainer({ 157 | childRect // from PopoverState, 158 | popoverRect // from PopoverState, 159 | position // from PopoverState, 160 | arrowColor // string, 161 | arrowSize // number, 162 | }); 163 | 164 | // ... 165 | 166 | // You can then use these styles to render your arrow container in whatever way you'd like 167 | return ( 168 |
169 |
170 | {children} 171 |
172 | ); 173 | ``` 174 | 175 | Similarly, `usePopover` allows you to create your own popover component as so: 176 | 177 | ```JSX 178 | import { usePopover } from 'react-tiny-popover' 179 | 180 | // ... 181 | 182 | const onPositionPopover = useCallback( 183 | (popoverState: PopoverState) => setPopoverState(popoverState), 184 | [], 185 | ); 186 | 187 | const [positionPopover, popoverRef] = usePopover({ 188 | childRef, 189 | containerClassName, 190 | parentElement, 191 | transform, 192 | positions, 193 | align, 194 | padding, 195 | boundaryInset, 196 | boundaryElement, 197 | reposition, 198 | onPositionPopover, 199 | }); 200 | 201 | // ... 202 | 203 | ``` 204 | 205 | After attaching `popoverRef` and `childRef` to the DOM, you can fire `positionPopover` at any time to update your popover's position. 206 | 207 | This is a bit more advanced, but play around and see what you can come up with! Feel free to examine the internal Popover component to see how the hook is used there. 208 | 209 | ## Small Breaking Change in 8.1 210 | 211 | Prior to 8.1, the two DOM elements generated via React Portal by `react-tiny-popover` were given the ids `react-tiny-popover-container` and `react-tiny-popover-scout`. In 8.1 and above, both `react-tiny-popover-container` and `react-tiny-popover-scout` are now assigned as class names. This solves the issue of multiple DOM elements sharing the same id if you have more than one popover open at once. 212 | 213 | If you select for `react-tiny-popover-container` or `react-tiny-popover-scout` by id in your code, you'll have to select via class name instead. 214 | 215 | ## Migrating to 8.0 216 | 217 | `react-tiny-popover` 8.0 removes the `contentLocation` prop and replaces it with a slightly more capable `transform` prop. By default, the `transform` prop behaves exactly as `contentLocation` did. 218 | 219 | ```JSX 220 | Hi! I'm popover content.
} 224 | > 225 | {/* ... */} 226 | ; 227 | ``` 228 | 229 | Becomes: 230 | 231 | ```JSX 232 | Hi! I'm popover content.} 236 | > 237 | {/* ... */} 238 | ; 239 | ``` 240 | 241 | Now, you have access to an additional handy prop, `transformMode`: 242 | 243 | ```JSX 244 | Hi! I'm popover content.} 249 | > 250 | {/* ... */} 251 | ; 252 | ``` 253 | 254 | The above popover will now render 20 pixels down and left of where it originally would have appeared without the transform, rather than at a fixed/absolute position. 255 | 256 | The other `transformMode` value, `"absolute"` is the default value when `transformMode` is omitted. This produces the same behavior that `contentLocation` did. 257 | 258 | ## Migrating to 5.0 259 | 260 | `react-tiny-popover` 5 and up has abandoned use of `findDOMNode` to gain a reference to `Popover`'s target DOM node, and now explicitly relies on a ref. Since React has deprecated `findDOMNode` in `StrictMode`, now seems like an appropriate time to shift away from this under-the-hood logic toward a clearer and more declarative API. 261 | 262 | If your code looked this way, it can stay this way. React elements handle refs out of the box with no issues: 263 | 264 | ```JSX 265 | Hi! I'm popover content.} 268 | > 269 |
setIsPopoverOpen(!isPopoverOpen)}> 270 | Click me! 271 |
272 |
; 273 | ``` 274 | 275 | However, if you use a custom component as a your `Popover`'s child, you'll have to implement ref forwarding. Without ref forwarding, `Popover` will not be able to inject a reference into your component and refer to it. 276 | 277 | For example: 278 | 279 | ```JSX 280 | interface Props extends React.ComponentPropsWithoutRef<'div'> { 281 | onClick(): void; 282 | } 283 | 284 | // this component will no longer work as a Popover child 285 | const CustomComponent: React.FC = props => ( 286 |
287 | {props.children} 288 |
289 | ) 290 | 291 | // instead, you'll simply implement ref forwarding, as so: 292 | const CustomComponent = React.forwardRef((props, ref) => ( 293 |
294 | {props.children} 295 |
296 | )); 297 | ``` 298 | 299 | Check out [React's ref forwarding API](https://reactjs.org/docs/forwarding-refs.html) for more info, and see the examples above. 300 | 301 | ## API 302 | 303 | ### Popover 304 | 305 | | Property | Type | Required | Description | 306 | | ------------------- | ------------------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 307 | | children | `JSX.Element` | ✔️ | The component you place here will render directly to the DOM. Totally headless. If you provide a custom component, it must use [ref forwarding](https://reactjs.org/docs/forwarding-refs.html). | 308 | | isOpen | `boolean` | ✔️ | When this boolean is set to true, the popover is visible and tracks the target. When the boolean is false, the popover content is neither visible nor present on the DOM. | 309 | | content | `JSX.Element` or `(popoverState: PopoverState) => JSX.Element` | ✔️ | Here, you'll provide the content that will appear as the popover. If you're providing a function, see `PopoverState` below. | 310 | | padding | `number` | | This number determines the gap, in pixels, between your target content and your popover content. Defaults to 0. | 311 | | reposition | `boolean` | | If false, rather than the popover content repositioning on a boundary collision, the popover content container will move beyond your `parentElement`'s bounds. You are, however, supplied with `nudgedLeft` and `nudgedTop` values by the function you can opt to provide to `content`, so you may choose to handle content overflow as you wish. | 312 | | positions | `string[]` | | You may provide a priority list of preferred positions for your popover content in relation to its target, in the form of an array. Valid values for the array are `'top', 'bottom', 'left', 'right'`. If the popover reaches the edge of the window or its otherwise specified boundary (see `parentElement` and `boundaryInset`), and repositioning is enabled, it will attempt to render in the order you specify. The default order is `['top', 'left', 'right', 'bottom']`. If you'd like, you can provide a shorter array like `['top', 'left']`. Once the array of positions is exhausted, the popover will no longer attempt to reposition. | 313 | | align | `string` | | Possible values are `start`, `center`, and `end`. If `start` is specified, the popover content's top or left location is aligned with its target's. With `end` specified, the content's bottom or right location is aligned with its target's. If `center` is specified, the popover content and target's centers are aligned. Defaults to `center`. | 314 | | ref | `React.Ref` | | Since `Popover` relies on ref forwarding to access its child, it's not simple to obtain a second reference to that child. This property acts as a "pass through" for you to obtain a ref to the child you've provided `Popover`. The value of the ref you provide here will be `Popover`'s child. | 315 | | onClickOutside | `(e: MouseEvent) => void` | | If `react-tiny-popover` detects a click event outside of the target and outside of the popover, you may handle this event here. | 316 | | clickOutsideCapture | `boolean` | | This boolean represents the `useCapture` option passed along as the third argument to the internal `window.addEventListener` used by `onClickOutside`. | 317 | | transform | `{ top: number; left: number}` or `(popoverState: PopoverState) => { top: number, left: number }` | | If you'd like to hook directly into the positioning process, you may do so here! `top` and `left` positions provided or returned here will override the popover content's (`popoverRect`) location in a fashion specified by the `transformMode` prop. | 318 | | transformMode | `"absolute"` or `"relative"` | | A value of `"absolute"` will popsition the popover at precisely the `top` and `left` values provided by `transform`, relative to the `parentElement`. A value of `"relative"` will "nudge" the popover from where it would appear pre-transform by the `top` and `left` values provided in `transform`. | 319 | | parentElement | `HTMLElement` | | Provide an HTML element ref here to have your popover content appended to that element rather than `document.body`. This is useful if you'd like your popover to sit at a particular place within the DOM. Supplying a `parentElement` ref will not in most cases directly affect the positioning of the popover. | 320 | | boundaryInset | `number` | | This number specifies the inset around your `parentElement`'s border that boundary violations are determined at. Defaults to 0. Can be negative. | 321 | | boundaryElement | `HTMLElement` | | If provided (and `reposition` is enabled), your popover will adhere to the boundaries of this element as determined by `Element.getBoundingDOMRect()`. | 322 | | containerStyle | `object` (`CSSStyleDeclaration`) | | Your popover content is rendered to the DOM in a single container `div`. If you'd like to apply style directly to this container `div`, you may do so here! Be aware that as this `div` is a DOM element and not a React element, all style values must be strings. For example, 5 pixels must be represented as `'5px'`, as you'd do with vanilla DOM manipulation in JavaScript. | 323 | | containerClassName | `string` | | If you'd like to apply styles to the single container `div` that your popover content is rendered within via stylesheets, you can specify a custom className for the container here. | 324 | 325 | ### PopoverState 326 | 327 | | Property | Type | Description | 328 | | -------------- | --------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 329 | | isPositioned | `boolean` | After the popover has positioned its contents, this field is true. Prior, it is false. | 330 | | childRect | `Rect` | The current rect of the popover's child (i.e., the source from which the popover renders). | 331 | | popoverRect | `Rect` | The current rect of the popover's contents. | 332 | | parentRect | `Rect` | The current rect of the popover child's parent. | 333 | | position | `'left'` \| `'right'` \| `'top'` \| `'bottom'` \| `undefined` | The current position of the popover in relation to the child. `undefined` implies the user has set an absolute transform. | 334 | | align | `'start'` \| `'center'` \| `'end'` \| `undefined` | The cross-axis alignment of the popover's contents. `undefined` implies the user has set an explicit `contentLocation`. | 335 | | padding | `number` | The distance between the popover's child and contents. If set to zero, the two are touching. | 336 | | nudgedLeft | `number` | If the popover's contents encounter a boundary violation that does not warrant a reposition, the contents are instead "nudged" by the appropriate top and left values to keep the contents within the boundary. This is the left value. | 337 | | nudgedTop | `number` | If the popover's contents encounter a boundary violation that does not warrant a reposition, the contents are instead "nudged" by the appropriate top and left values to keep the contents within the boundary. This is the top value. | 338 | | boundaryInset | `number` | The popover's contents will encounter boundary violations prior to the actual `parentElement`'s boundaries by this number in pixels. Can be negative. | 339 | | boundaryRect | `Rect` | The current rect of the popover's boundaries. | 340 | | transform | `{ top?: number; left?: number; }` \| undefined | The values you provided to the `transform` prop, if they exist. | 341 | | violations | `{ top: number; left: number; bottom: number; right: number; }` | An object containing boundary violations. Expect a value of `0` if no boundary violation exists at that bound (i.e., your popover is entirely within that bound), and expect positive values representing pixels beyond that bound if a violation exists (i.e., your popover exceeds the `top` bound by ten pixels, `top` will be `10`). | 342 | | hasViolations | `boolean` | `true` if violations exist at any boundary, `false` otherwise. | 343 | 344 | ### ArrowContainer 345 | 346 | | Property | Type | Required | Description | 347 | | -------------- | ------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 348 | | position | `string` | ✔️ | The `ArrowContainer` needs to know its own position in relation to the target, so it can point in the correct direction! | 349 | | children | `JSX.Element` | ✔️ | You'll provide the `ArrowContainer` with a JSX.Element child to render as your popover content. | 350 | | targetRect | `object` | ✔️ | The `ArrowContainer` must know its target's bounding rect in order to position its arrow properly. This object is of type `{ width: number, height: number, top: number, left: number, right: number, bottom: number }`. | 351 | | popoverRect | `object` | ✔️ | This allows the `ArrowContainer` to know its own bounding rect in order to position its arrow properly. This object is of type `{ width: number, height: number, top: number, left: number, right: number, bottom: number }`. | 352 | | arrowSize | `number` | | The size of the triangle arrow. Defaults to 10 or something like that. | 353 | | arrowColor | `string` | | The color of the arrow! Exciting. | 354 | | arrowStyle | `object` | | You may append to the arrow's style here. | 355 | | style | `object` | | If you'd like to append to the style of the `ArrowContainer` itself, do so here. Rad. | 356 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const globals = require('globals'); 2 | const pluginJs = require('@eslint/js'); 3 | const tseslint = require('typescript-eslint'); 4 | const pluginReact = require('eslint-plugin-react'); 5 | const eslintConfigPrettier = require('eslint-config-prettier'); 6 | 7 | module.exports = [ 8 | { languageOptions: { globals: globals.browser } }, 9 | pluginJs.configs.recommended, 10 | ...tseslint.configs.recommended, 11 | pluginReact.configs.flat.recommended, 12 | eslintConfigPrettier, 13 | { 14 | files: ['**/*.{ts,tsx}'], 15 | rules: { 16 | 'react/react-in-jsx-scope': 'off', 17 | 'react/display-name': 'off', 18 | }, 19 | }, 20 | ]; 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-tiny-popover", 3 | "version": "8.1.6", 4 | "description": "A simple and highly customizable popover react higher order component with no other dependencies!", 5 | "keywords": [ 6 | "react", 7 | "popover", 8 | "react-popover", 9 | "popout", 10 | "pop", 11 | "out", 12 | "modal" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/alexkatz/react-tiny-popover.git" 17 | }, 18 | "files": [ 19 | "dist" 20 | ], 21 | "license": "MIT", 22 | "author": "Alex Katz", 23 | "main": "dist/Popover.js", 24 | "types": "dist/index.d.ts", 25 | "scripts": { 26 | "build": "tsc -p . && pnpm run copy-types", 27 | "clean": "rimraf dist/", 28 | "copy-types": "shx cp src/index.d.ts dist/index.d.ts", 29 | "start-demo": "cd docs && pnpm run start", 30 | "watch": "tsc-watch -p . --onSuccess 'pnpm run copy-types'" 31 | }, 32 | "devDependencies": { 33 | "@eslint/js": "^9.10.0", 34 | "@types/react": "^18.3.12", 35 | "@types/react-dom": "^18.3.1", 36 | "eslint": "^9.10.0", 37 | "eslint-config-prettier": "^9.1.0", 38 | "eslint-config-react-app": "^7.0.1", 39 | "eslint-plugin-react": "^7.36.1", 40 | "globals": "^15.12.0", 41 | "prettier": "^3.3.3", 42 | "rimraf": "^4.4.1", 43 | "shx": "^0.3.4", 44 | "tsc-watch": "^6.2.1", 45 | "typescript": "5.6.3", 46 | "typescript-eslint": "^8.5.0" 47 | }, 48 | "peerDependencies": { 49 | "react": ">=16.8.0", 50 | "react-dom": ">=16.8.0" 51 | }, 52 | "packageManager": "pnpm@10.1.0+sha512.c89847b0667ddab50396bbbd008a2a43cf3b581efd59cf5d9aa8923ea1fb4b8106c041d540d08acb095037594d73ebc51e1ec89ee40c88b30b8a66c0fae0ac1b" 53 | } 54 | -------------------------------------------------------------------------------- /react-tiny-popover-short-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/alexkatz/react-tiny-popover/fecaaef1fbd61bbe0dd0b22be38635c57af6ceed/react-tiny-popover-short-demo.gif -------------------------------------------------------------------------------- /src/ArrowContainer.tsx: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import { ArrowContainerProps } from '.'; 3 | import { useArrowContainer } from './useArrowContainer'; 4 | 5 | export const ArrowContainer = ({ 6 | childRect, 7 | popoverRect, 8 | position, 9 | arrowColor, 10 | arrowSize, 11 | arrowClassName, 12 | arrowStyle: externalArrowStyle, 13 | className, 14 | children, 15 | style: externalArrowContainerStyle, 16 | }: ArrowContainerProps) => { 17 | const { arrowContainerStyle, arrowStyle } = useArrowContainer({ 18 | childRect, 19 | popoverRect, 20 | position, 21 | arrowColor, 22 | arrowSize, 23 | }); 24 | 25 | const mergedContainerStyle = useMemo( 26 | () => ({ 27 | ...arrowContainerStyle, 28 | ...externalArrowContainerStyle, 29 | }), 30 | [arrowContainerStyle, externalArrowContainerStyle], 31 | ); 32 | 33 | const mergedArrowStyle = useMemo( 34 | () => ({ 35 | ...arrowStyle, 36 | ...externalArrowStyle, 37 | }), 38 | [arrowStyle, externalArrowStyle], 39 | ); 40 | 41 | return ( 42 |
43 |
44 | {children} 45 |
46 | ); 47 | }; 48 | -------------------------------------------------------------------------------- /src/Popover.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | useRef, 3 | useLayoutEffect, 4 | useState, 5 | useCallback, 6 | useEffect, 7 | forwardRef, 8 | cloneElement, 9 | Ref, 10 | } from 'react'; 11 | import { PopoverPortal } from './PopoverPortal'; 12 | import { PopoverPosition, PopoverProps, PopoverState } from '.'; 13 | import { EMPTY_RECT, rectsAreEqual } from './util'; 14 | import { usePopover } from './usePopover'; 15 | import { useMemoizedArray } from './useMemoizedArray'; 16 | import { useHandlePrevValues } from './useHandlePrevValues'; 17 | export { useArrowContainer } from './useArrowContainer'; 18 | export { ArrowContainer } from './ArrowContainer'; 19 | export { usePopover }; 20 | 21 | const DEFAULT_POSITIONS: PopoverPosition[] = ['top', 'left', 'right', 'bottom']; 22 | 23 | const PopoverInternal = forwardRef( 24 | ( 25 | { 26 | isOpen, 27 | children, 28 | content, 29 | positions: externalPositions = DEFAULT_POSITIONS, 30 | align = 'center', 31 | padding = 0, 32 | reposition = true, 33 | parentElement = window.document.body, 34 | boundaryElement = parentElement, 35 | containerClassName, 36 | containerStyle, 37 | transform, 38 | transformMode = 'absolute', 39 | boundaryInset = 0, 40 | onClickOutside, 41 | clickOutsideCapture = false, 42 | }: PopoverProps, 43 | externalRef: Ref, 44 | ) => { 45 | const positions = useMemoizedArray( 46 | Array.isArray(externalPositions) ? externalPositions : [externalPositions], 47 | ); 48 | 49 | const { prev, updatePrevValues } = useHandlePrevValues({ 50 | positions, 51 | reposition, 52 | transformMode, 53 | transform, 54 | boundaryElement, 55 | boundaryInset, 56 | }); 57 | 58 | const childRef = useRef(); 59 | 60 | const [popoverState, setPopoverState] = useState({ 61 | align, 62 | nudgedLeft: 0, 63 | nudgedTop: 0, 64 | position: positions[0], 65 | padding, 66 | childRect: EMPTY_RECT, 67 | popoverRect: EMPTY_RECT, 68 | parentRect: EMPTY_RECT, 69 | boundaryRect: EMPTY_RECT, 70 | boundaryInset, 71 | violations: EMPTY_RECT, 72 | hasViolations: false, 73 | }); 74 | 75 | const onPositionPopover = useCallback( 76 | (popoverState: PopoverState) => setPopoverState(popoverState), 77 | [], 78 | ); 79 | 80 | const { positionPopover, popoverRef, scoutRef } = usePopover({ 81 | isOpen, 82 | childRef, 83 | containerClassName, 84 | parentElement, 85 | boundaryElement, 86 | transform, 87 | transformMode, 88 | positions, 89 | align, 90 | padding, 91 | boundaryInset, 92 | reposition, 93 | onPositionPopover, 94 | }); 95 | 96 | useLayoutEffect(() => { 97 | let shouldUpdate = true; 98 | 99 | const updatePopover = () => { 100 | if (isOpen && shouldUpdate) { 101 | const childRect = childRef?.current?.getBoundingClientRect(); 102 | const popoverRect = popoverRef?.current?.getBoundingClientRect(); 103 | if ( 104 | childRect != null && 105 | popoverRect != null && 106 | (!rectsAreEqual(childRect, popoverState.childRect) || 107 | popoverRect.width !== popoverState.popoverRect.width || 108 | popoverRect.height !== popoverState.popoverRect.height || 109 | popoverState.padding !== padding || 110 | popoverState.align !== align || 111 | positions !== prev.positions || 112 | reposition !== prev.reposition || 113 | transformMode !== prev.transformMode || 114 | transform !== prev.transform || 115 | boundaryElement !== prev.boundaryElement || 116 | boundaryInset !== prev.boundaryInset) 117 | ) { 118 | positionPopover(); 119 | } 120 | 121 | updatePrevValues(); 122 | 123 | if (shouldUpdate) { 124 | window.requestAnimationFrame(updatePopover); 125 | } 126 | } 127 | }; 128 | 129 | updatePopover(); 130 | 131 | return () => { 132 | shouldUpdate = false; 133 | }; 134 | }, [ 135 | align, 136 | boundaryElement, 137 | boundaryInset, 138 | isOpen, 139 | padding, 140 | popoverRef, 141 | popoverState.align, 142 | popoverState.childRect, 143 | popoverState.padding, 144 | popoverState.popoverRect.height, 145 | popoverState.popoverRect.width, 146 | positionPopover, 147 | positions, 148 | prev.boundaryElement, 149 | prev.boundaryInset, 150 | prev.positions, 151 | prev.reposition, 152 | prev.transform, 153 | prev.transformMode, 154 | reposition, 155 | transform, 156 | transformMode, 157 | updatePrevValues, 158 | ]); 159 | 160 | useEffect(() => { 161 | const popoverElement = popoverRef.current; 162 | 163 | Object.assign(popoverElement.style, containerStyle); 164 | 165 | return () => { 166 | Object.keys(containerStyle ?? {}).forEach( 167 | (key) => 168 | delete popoverElement.style[ 169 | key as keyof Omit 170 | ], 171 | ); 172 | }; 173 | }, [containerStyle, isOpen, popoverRef]); 174 | 175 | const handleOnClickOutside = useCallback( 176 | (e: MouseEvent) => { 177 | if ( 178 | isOpen && 179 | !popoverRef.current?.contains(e.target as Node) && 180 | !childRef.current?.contains(e.target as Node) 181 | ) { 182 | onClickOutside?.(e); 183 | } 184 | }, 185 | [isOpen, onClickOutside, popoverRef], 186 | ); 187 | 188 | const handleWindowResize = useCallback(() => { 189 | if (childRef.current && isOpen) { 190 | window.requestAnimationFrame(() => positionPopover()); 191 | } 192 | }, [positionPopover, isOpen]); 193 | 194 | useEffect(() => { 195 | const body = parentElement.ownerDocument.body; 196 | 197 | body.addEventListener('click', handleOnClickOutside, clickOutsideCapture); 198 | body.addEventListener('contextmenu', handleOnClickOutside, clickOutsideCapture); 199 | 200 | window.addEventListener('resize', handleWindowResize); 201 | 202 | return () => { 203 | body.removeEventListener('click', handleOnClickOutside, clickOutsideCapture); 204 | body.removeEventListener('contextmenu', handleOnClickOutside, clickOutsideCapture); 205 | 206 | window.removeEventListener('resize', handleWindowResize); 207 | }; 208 | }, [clickOutsideCapture, handleOnClickOutside, handleWindowResize, parentElement]); 209 | 210 | const handleRef = useCallback( 211 | (node: HTMLElement) => { 212 | childRef.current = node; 213 | if (externalRef != null) { 214 | if (typeof externalRef === 'object') { 215 | (externalRef as React.MutableRefObject).current = node; 216 | } else if (typeof externalRef === 'function') { 217 | (externalRef as (instance: HTMLElement) => void)(node); 218 | } 219 | } 220 | }, 221 | [externalRef], 222 | ); 223 | 224 | const renderChild = () => cloneElement(children, { ref: handleRef }); 225 | 226 | const renderPopover = () => { 227 | if (!isOpen) return null; 228 | return ( 229 | 234 | {typeof content === 'function' ? content(popoverState) : content} 235 | 236 | ); 237 | }; 238 | 239 | return ( 240 | <> 241 | {renderChild()} 242 | {renderPopover()} 243 | 244 | ); 245 | }, 246 | ); 247 | 248 | export const Popover = forwardRef((props, ref) => { 249 | if (typeof window === 'undefined') return props.children; 250 | return ; 251 | }); 252 | -------------------------------------------------------------------------------- /src/PopoverPortal.tsx: -------------------------------------------------------------------------------- 1 | import React, { useLayoutEffect } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | type PopoverPortalProps = { 5 | container: Element; 6 | element: Element; 7 | scoutElement: Element; 8 | children: React.ReactNode; 9 | }; 10 | 11 | export const PopoverPortal = ({ 12 | container, 13 | element, 14 | scoutElement, 15 | children, 16 | }: PopoverPortalProps) => { 17 | useLayoutEffect(() => { 18 | container.appendChild(element); 19 | container.appendChild(scoutElement); 20 | return () => { 21 | container.removeChild(element); 22 | container.removeChild(scoutElement); 23 | }; 24 | }, [container, element, scoutElement]); 25 | 26 | return createPortal(children, element); 27 | }; 28 | -------------------------------------------------------------------------------- /src/index.d.ts: -------------------------------------------------------------------------------- 1 | export type Rect = { 2 | top: number; 3 | left: number; 4 | right: number; 5 | bottom: number; 6 | width: number; 7 | height: number; 8 | }; 9 | 10 | export type BoundaryViolations = { 11 | top: number; 12 | left: number; 13 | right: number; 14 | bottom: number; 15 | }; 16 | 17 | export type PopoverState = { 18 | childRect: Rect; 19 | popoverRect: Rect; 20 | parentRect: Rect; 21 | boundaryRect: Rect; 22 | position?: PopoverPosition; 23 | align?: PopoverAlign; 24 | padding: number; 25 | transform?: PositionTransformValue; 26 | nudgedLeft: number; 27 | nudgedTop: number; 28 | boundaryInset: number; 29 | violations: BoundaryViolations; 30 | hasViolations: boolean; 31 | }; 32 | 33 | export type ContentRenderer = (popoverState: PopoverState) => JSX.Element; 34 | 35 | export type PositionTransformValue = { 36 | top?: number; 37 | left?: number; 38 | }; 39 | 40 | export type PositionTransform = 41 | | PositionTransformValue 42 | | ((popoverState: PopoverState) => PositionTransformValue); 43 | 44 | export type PopoverPosition = 'left' | 'right' | 'top' | 'bottom'; 45 | export type PopoverAlign = 'start' | 'center' | 'end'; 46 | 47 | export type UseArrowContainerProps = { 48 | childRect: Rect; 49 | popoverRect: Rect; 50 | position?: PopoverPosition; 51 | arrowSize: number; 52 | arrowColor: string; 53 | }; 54 | 55 | export type ArrowContainerProps = UseArrowContainerProps & { 56 | children: JSX.Element; 57 | className?: string; 58 | style?: React.CSSProperties; 59 | arrowStyle?: React.CSSProperties; 60 | arrowClassName?: string; 61 | }; 62 | 63 | export type BasePopoverProps = { 64 | isOpen: boolean; 65 | align?: PopoverAlign; 66 | padding?: number; 67 | reposition?: boolean; 68 | parentElement?: HTMLElement; 69 | boundaryElement?: HTMLElement; 70 | boundaryInset?: number; 71 | containerClassName?: string; 72 | transform?: PositionTransform; 73 | transformMode?: 'relative' | 'absolute'; 74 | }; 75 | 76 | export type UsePopoverProps = BasePopoverProps & { 77 | childRef: React.MutableRefObject; 78 | positions: PopoverPosition[]; 79 | onPositionPopover(popoverState: PopoverState): void; 80 | }; 81 | 82 | export type PopoverProps = BasePopoverProps & { 83 | children: JSX.Element; 84 | positions?: PopoverPosition[] | PopoverPosition; 85 | content: ContentRenderer | JSX.Element; 86 | ref?: React.Ref; 87 | containerStyle?: Partial; 88 | onClickOutside?: (e: MouseEvent) => void; 89 | clickOutsideCapture?: boolean; 90 | }; 91 | 92 | export type PositionPopoverProps = { 93 | positionIndex?: number; 94 | childRect?: Rect; 95 | popoverRect?: Rect; 96 | parentRect?: Rect; 97 | scoutRect?: Rect; 98 | parentRectAdjusted?: Rect; 99 | boundaryRect?: Rect; 100 | }; 101 | 102 | export type PositionPopover = (props?: PositionPopoverProps) => void; 103 | 104 | export type PopoverRefs = { 105 | popoverRef: React.MutableRefObject; 106 | scoutRef: React.MutableRefObject; 107 | }; 108 | 109 | export type UsePopoverResult = { 110 | positionPopover: PositionPopover; 111 | popoverRef: React.MutableRefObject; 112 | scoutRef: React.MutableRefObject; 113 | }; 114 | 115 | export type UseArrowContainerResult = { 116 | arrowStyle: React.CSSProperties; 117 | arrowContainerStyle: React.CSSProperties; 118 | }; 119 | 120 | export const usePopover: (props: UsePopoverProps) => UsePopoverResult; 121 | export const useArrowContainer: (props: UseArrowContainerProps) => UseArrowContainerResult; 122 | 123 | export const Popover: (props: PopoverProps) => JSX.Element | null; 124 | export const ArrowContainer: (props: ArrowContainerProps) => JSX.Element | null; 125 | -------------------------------------------------------------------------------- /src/useArrowContainer.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties, useMemo } from 'react'; 2 | import { UseArrowContainerProps } from '.'; 3 | 4 | export const useArrowContainer = ({ 5 | childRect, 6 | popoverRect, 7 | position, 8 | arrowSize, 9 | arrowColor, 10 | }: UseArrowContainerProps) => { 11 | const arrowContainerStyle = useMemo( 12 | () => 13 | ({ 14 | padding: arrowSize, 15 | }) as CSSProperties, 16 | [arrowSize], 17 | ); 18 | 19 | const arrowStyle = useMemo( 20 | () => 21 | ({ 22 | position: 'absolute', 23 | ...((): CSSProperties => { 24 | const arrowWidth = arrowSize * 2; 25 | let top = childRect.top - popoverRect.top + childRect.height / 2 - arrowWidth / 2; 26 | let left = childRect.left - popoverRect.left + childRect.width / 2 - arrowWidth / 2; 27 | 28 | const lowerBound = arrowSize; 29 | const leftUpperBound = popoverRect.width - arrowSize; 30 | const topUpperBound = popoverRect.height - arrowSize; 31 | 32 | left = left < lowerBound ? lowerBound : left; 33 | left = left + arrowWidth > leftUpperBound ? leftUpperBound - arrowWidth : left; 34 | top = top < lowerBound ? lowerBound : top; 35 | top = top + arrowWidth > topUpperBound ? topUpperBound - arrowWidth : top; 36 | 37 | top = Number.isNaN(top) ? 0 : top; 38 | left = Number.isNaN(left) ? 0 : left; 39 | 40 | switch (position) { 41 | case 'right': 42 | return { 43 | borderTop: `${arrowSize}px solid transparent`, 44 | borderBottom: `${arrowSize}px solid transparent`, 45 | borderRight: `${arrowSize}px solid ${arrowColor}`, 46 | left: 0, 47 | top, 48 | }; 49 | case 'left': 50 | return { 51 | borderTop: `${arrowSize}px solid transparent`, 52 | borderBottom: `${arrowSize}px solid transparent`, 53 | borderLeft: `${arrowSize}px solid ${arrowColor}`, 54 | right: 0, 55 | top, 56 | }; 57 | case 'bottom': 58 | return { 59 | borderLeft: `${arrowSize}px solid transparent`, 60 | borderRight: `${arrowSize}px solid transparent`, 61 | borderBottom: `${arrowSize}px solid ${arrowColor}`, 62 | top: 0, 63 | left, 64 | }; 65 | case 'top': 66 | return { 67 | borderLeft: `${arrowSize}px solid transparent`, 68 | borderRight: `${arrowSize}px solid transparent`, 69 | borderTop: `${arrowSize}px solid ${arrowColor}`, 70 | bottom: 0, 71 | left, 72 | }; 73 | default: 74 | return { 75 | display: 'hidden', 76 | }; 77 | } 78 | })(), 79 | }) as CSSProperties, 80 | [ 81 | arrowColor, 82 | arrowSize, 83 | childRect.height, 84 | childRect.left, 85 | childRect.top, 86 | childRect.width, 87 | popoverRect.height, 88 | popoverRect.left, 89 | popoverRect.top, 90 | popoverRect.width, 91 | position, 92 | ], 93 | ); 94 | 95 | return { 96 | arrowContainerStyle, 97 | arrowStyle, 98 | }; 99 | }; 100 | -------------------------------------------------------------------------------- /src/useElementRef.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useLayoutEffect } from 'react'; 2 | import { CreateContainerProps, createContainer } from './util'; 3 | 4 | export const useElementRef = ({ containerClassName, containerStyle }: CreateContainerProps) => { 5 | const ref = useRef(); 6 | 7 | const [element] = useState(() => 8 | createContainer({ containerStyle, containerClassName: containerClassName }), 9 | ); 10 | 11 | useLayoutEffect(() => { 12 | element.className = containerClassName; 13 | }, [containerClassName, element]); 14 | 15 | useLayoutEffect(() => { 16 | Object.assign(element.style, containerStyle); 17 | }, [containerStyle, element]); 18 | 19 | ref.current = element; 20 | 21 | return ref; 22 | }; 23 | -------------------------------------------------------------------------------- /src/useHandlePrevValues.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef } from 'react'; 2 | import { PopoverProps } from '.'; 3 | 4 | export const useHandlePrevValues = (props: Partial) => { 5 | const prevPositions = useRef(props.positions); 6 | const prevReposition = useRef(props.reposition); 7 | const prevTransformMode = useRef(props.transformMode); 8 | const prevTransform = useRef(props.transform); 9 | const prevBoundaryElement = useRef(props.boundaryElement); 10 | const prevBoundaryInset = useRef(props.boundaryInset); 11 | 12 | const updatePrevValues = useCallback(() => { 13 | prevPositions.current = props.positions; 14 | prevReposition.current = props.reposition; 15 | prevTransformMode.current = props.transformMode; 16 | prevTransform.current = props.transform; 17 | prevBoundaryElement.current = props.boundaryElement; 18 | prevBoundaryInset.current = props.boundaryInset; 19 | }, [ 20 | props.boundaryElement, 21 | props.boundaryInset, 22 | props.positions, 23 | props.reposition, 24 | props.transform, 25 | props.transformMode, 26 | ]); 27 | 28 | return { 29 | prev: { 30 | positions: prevPositions.current, 31 | reposition: prevReposition.current, 32 | transformMode: prevTransformMode.current, 33 | transform: prevTransform.current, 34 | boundaryElement: prevBoundaryElement.current, 35 | boundaryInset: prevBoundaryInset.current, 36 | }, 37 | updatePrevValues, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/useMemoizedArray.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useMemo } from 'react'; 2 | 3 | export const useMemoizedArray = (externalArray: T[]) => { 4 | const prevArrayRef = useRef(externalArray); 5 | const array = useMemo(() => { 6 | if (prevArrayRef.current === externalArray) return prevArrayRef.current; 7 | 8 | if (prevArrayRef.current.length !== externalArray.length) { 9 | prevArrayRef.current = externalArray; 10 | return externalArray; 11 | } 12 | 13 | for (let i = 0; i < externalArray.length; i += 1) { 14 | if (externalArray[i] !== prevArrayRef.current[i]) { 15 | prevArrayRef.current = externalArray; 16 | return externalArray; 17 | } 18 | } 19 | 20 | return prevArrayRef.current; 21 | }, [externalArray]); 22 | 23 | return array; 24 | }; 25 | -------------------------------------------------------------------------------- /src/usePopover.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { 3 | BoundaryViolations, 4 | PopoverState, 5 | PositionPopover, 6 | UsePopoverProps, 7 | UsePopoverResult, 8 | } from '.'; 9 | import { EMPTY_RECT, createRect, getNewPopoverRect, getNudgedPopoverRect } from './util'; 10 | import { useElementRef } from './useElementRef'; 11 | 12 | const POPOVER_STYLE: Partial = { 13 | position: 'fixed', 14 | overflow: 'visible', 15 | top: '0px', 16 | left: '0px', 17 | }; 18 | 19 | const SCOUT_STYLE: Partial = { 20 | position: 'fixed', 21 | top: '0px', 22 | left: '0px', 23 | width: '0px', 24 | height: '0px', 25 | visibility: 'hidden', 26 | }; 27 | 28 | export const usePopover = ({ 29 | isOpen, 30 | childRef, 31 | positions, 32 | containerClassName, 33 | parentElement, 34 | transform, 35 | transformMode, 36 | align, 37 | padding, 38 | reposition, 39 | boundaryInset, 40 | boundaryElement, 41 | onPositionPopover, 42 | }: UsePopoverProps): UsePopoverResult => { 43 | const scoutRef = useElementRef({ 44 | containerClassName: 'react-tiny-popover-scout', 45 | containerStyle: SCOUT_STYLE, 46 | }); 47 | 48 | const popoverRef = useElementRef({ 49 | containerClassName: 50 | containerClassName != null && 51 | containerClassName.length > 0 && 52 | containerClassName !== 'react-tiny-popover-container' 53 | ? `react-tiny-popover-container ${containerClassName}` 54 | : 'react-tiny-popover-container', 55 | containerStyle: POPOVER_STYLE, 56 | }); 57 | 58 | const positionPopover = useCallback( 59 | ({ 60 | positionIndex = 0, 61 | parentRect = parentElement.getBoundingClientRect(), 62 | childRect = childRef?.current?.getBoundingClientRect(), 63 | scoutRect = scoutRef?.current?.getBoundingClientRect(), 64 | popoverRect = popoverRef.current.getBoundingClientRect(), 65 | boundaryRect = boundaryElement === parentElement 66 | ? parentRect 67 | : boundaryElement.getBoundingClientRect(), 68 | } = {}) => { 69 | if (!childRect || !parentRect || !isOpen) { 70 | return; 71 | } 72 | 73 | if (transform && transformMode === 'absolute') { 74 | const { top: inputTop, left: inputLeft } = 75 | typeof transform === 'function' 76 | ? transform({ 77 | childRect, 78 | popoverRect, 79 | parentRect, 80 | boundaryRect, 81 | padding, 82 | align, 83 | nudgedTop: 0, 84 | nudgedLeft: 0, 85 | boundaryInset, 86 | violations: EMPTY_RECT, 87 | hasViolations: false, 88 | }) 89 | : transform; 90 | 91 | const finalLeft = Math.round(parentRect.left + inputLeft - scoutRect.left); 92 | const finalTop = Math.round(parentRect.top + inputTop - scoutRect.top); 93 | 94 | popoverRef.current.style.transform = `translate(${finalLeft}px, ${finalTop}px)`; 95 | 96 | onPositionPopover({ 97 | childRect, 98 | popoverRect: createRect({ 99 | left: finalLeft, 100 | top: finalTop, 101 | width: popoverRect.width, 102 | height: popoverRect.height, 103 | }), 104 | parentRect, 105 | boundaryRect, 106 | padding, 107 | align, 108 | transform: { top: inputTop, left: inputLeft }, 109 | nudgedTop: 0, 110 | nudgedLeft: 0, 111 | boundaryInset, 112 | violations: EMPTY_RECT, 113 | hasViolations: false, 114 | }); 115 | 116 | return; 117 | } 118 | 119 | const isExhausted = positionIndex === positions.length; 120 | const position = isExhausted ? positions[0] : positions[positionIndex]; 121 | 122 | const { rect, boundaryViolation } = getNewPopoverRect( 123 | { 124 | childRect, 125 | popoverRect, 126 | boundaryRect, 127 | position, 128 | align, 129 | padding, 130 | reposition, 131 | }, 132 | boundaryInset, 133 | ); 134 | 135 | if (boundaryViolation && reposition && !isExhausted) { 136 | positionPopover({ 137 | positionIndex: positionIndex + 1, 138 | childRect, 139 | popoverRect, 140 | parentRect, 141 | boundaryRect, 142 | }); 143 | return; 144 | } 145 | 146 | const { top, left, width, height } = rect; 147 | const shouldNudge = reposition && !isExhausted; 148 | const { left: nudgedLeft, top: nudgedTop } = getNudgedPopoverRect( 149 | rect, 150 | boundaryRect, 151 | boundaryInset, 152 | ); 153 | 154 | let finalTop = top; 155 | let finalLeft = left; 156 | 157 | if (shouldNudge) { 158 | finalTop = nudgedTop; 159 | finalLeft = nudgedLeft; 160 | } 161 | 162 | finalTop = Math.round(finalTop - scoutRect.top); 163 | finalLeft = Math.round(finalLeft - scoutRect.left); 164 | 165 | popoverRef.current.style.transform = `translate(${finalLeft}px, ${finalTop}px)`; 166 | 167 | const potentialViolations: BoundaryViolations = { 168 | top: boundaryRect.top + boundaryInset - finalTop, 169 | left: boundaryRect.left + boundaryInset - finalLeft, 170 | right: finalLeft + width - boundaryRect.right + boundaryInset, 171 | bottom: finalTop + height - boundaryRect.bottom + boundaryInset, 172 | }; 173 | 174 | const popoverState: PopoverState = { 175 | childRect, 176 | popoverRect: createRect({ left: finalLeft, top: finalTop, width, height }), 177 | parentRect, 178 | boundaryRect, 179 | position, 180 | align, 181 | padding, 182 | nudgedTop: nudgedTop - top, 183 | nudgedLeft: nudgedLeft - left, 184 | boundaryInset, 185 | violations: { 186 | top: potentialViolations.top <= 0 ? 0 : potentialViolations.top, 187 | left: potentialViolations.left <= 0 ? 0 : potentialViolations.left, 188 | right: potentialViolations.right <= 0 ? 0 : potentialViolations.right, 189 | bottom: potentialViolations.bottom <= 0 ? 0 : potentialViolations.bottom, 190 | }, 191 | hasViolations: 192 | potentialViolations.top > 0 || 193 | potentialViolations.left > 0 || 194 | potentialViolations.right > 0 || 195 | potentialViolations.bottom > 0, 196 | }; 197 | 198 | if (transform) { 199 | onPositionPopover(popoverState); 200 | const { top: transformTop, left: transformLeft } = 201 | typeof transform === 'function' ? transform(popoverState) : transform; 202 | 203 | popoverRef.current.style.transform = `translate(${Math.round( 204 | finalLeft + (transformLeft ?? 0), 205 | )}px, ${Math.round(finalTop + (transformTop ?? 0))}px)`; 206 | 207 | popoverState.nudgedLeft += transformLeft ?? 0; 208 | popoverState.nudgedTop += transformTop ?? 0; 209 | popoverState.transform = { top: transformTop, left: transformLeft }; 210 | } 211 | 212 | onPositionPopover(popoverState); 213 | }, 214 | [ 215 | parentElement, 216 | childRef, 217 | scoutRef, 218 | popoverRef, 219 | boundaryElement, 220 | isOpen, 221 | transform, 222 | transformMode, 223 | positions, 224 | align, 225 | padding, 226 | reposition, 227 | boundaryInset, 228 | onPositionPopover, 229 | ], 230 | ); 231 | 232 | return { positionPopover, popoverRef, scoutRef } as const; 233 | }; 234 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { PopoverPosition, PopoverAlign, Rect } from './index'; 2 | 3 | export const EMPTY_RECT: Rect = { 4 | top: 0, 5 | left: 0, 6 | right: 0, 7 | bottom: 0, 8 | width: 0, 9 | height: 0, 10 | }; 11 | 12 | export type CreateRectProps = { 13 | top: number; 14 | left: number; 15 | width: number; 16 | height: number; 17 | }; 18 | 19 | export const createRect = ({ top, left, width, height }: CreateRectProps) => ({ 20 | top, 21 | left, 22 | width, 23 | height, 24 | right: left + width, 25 | bottom: top + height, 26 | }); 27 | 28 | export const rectsAreEqual = (rectA: Rect, rectB: Rect) => 29 | rectA === rectB || 30 | (rectA?.bottom === rectB?.bottom && 31 | rectA?.height === rectB?.height && 32 | rectA?.left === rectB?.left && 33 | rectA?.right === rectB?.right && 34 | rectA?.top === rectB?.top && 35 | rectA?.width === rectB?.width); 36 | 37 | export type CreateContainerProps = { 38 | containerStyle?: Partial; 39 | containerClassName?: string; 40 | }; 41 | 42 | export const createContainer = ({ containerStyle, containerClassName }: CreateContainerProps) => { 43 | const container = window.document.createElement('div'); 44 | if (containerClassName) container.className = containerClassName; 45 | Object.assign(container.style, containerStyle); 46 | return container; 47 | }; 48 | 49 | export const popoverRectForPosition = ( 50 | position: PopoverPosition, 51 | childRect: Rect, 52 | popoverRect: Rect, 53 | padding: number, 54 | align: PopoverAlign, 55 | ): Rect => { 56 | const targetMidX = childRect.left + childRect.width / 2; 57 | const targetMidY = childRect.top + childRect.height / 2; 58 | const { width, height } = popoverRect; 59 | let top: number; 60 | let left: number; 61 | 62 | switch (position) { 63 | case 'left': 64 | top = targetMidY - height / 2; 65 | left = childRect.left - padding - width; 66 | if (align === 'start') { 67 | top = childRect.top; 68 | } 69 | if (align === 'end') { 70 | top = childRect.bottom - height; 71 | } 72 | break; 73 | case 'bottom': 74 | top = childRect.bottom + padding; 75 | left = targetMidX - width / 2; 76 | if (align === 'start') { 77 | left = childRect.left; 78 | } 79 | if (align === 'end') { 80 | left = childRect.right - width; 81 | } 82 | break; 83 | case 'right': 84 | top = targetMidY - height / 2; 85 | left = childRect.right + padding; 86 | if (align === 'start') { 87 | top = childRect.top; 88 | } 89 | if (align === 'end') { 90 | top = childRect.bottom - height; 91 | } 92 | break; 93 | default: 94 | top = childRect.top - height - padding; 95 | left = targetMidX - width / 2; 96 | if (align === 'start') { 97 | left = childRect.left; 98 | } 99 | if (align === 'end') { 100 | left = childRect.right - width; 101 | } 102 | break; 103 | } 104 | 105 | return createRect({ left, top, width, height }); 106 | }; 107 | 108 | interface GetNewPopoverRectProps { 109 | position: PopoverPosition; 110 | reposition: boolean; 111 | align: PopoverAlign; 112 | childRect: Rect; 113 | popoverRect: Rect; 114 | boundaryRect: Rect; 115 | padding: number; 116 | } 117 | 118 | export const getNewPopoverRect = ( 119 | { 120 | position, 121 | align, 122 | childRect, 123 | popoverRect, 124 | boundaryRect, 125 | padding, 126 | reposition, 127 | }: GetNewPopoverRectProps, 128 | boundaryInset: number, 129 | ) => { 130 | const rect = popoverRectForPosition(position, childRect, popoverRect, padding, align); 131 | 132 | const boundaryViolation = 133 | reposition && 134 | ((position === 'top' && rect.top < boundaryRect.top + boundaryInset) || 135 | (position === 'left' && rect.left < boundaryRect.left + boundaryInset) || 136 | (position === 'right' && rect.right > boundaryRect.right - boundaryInset) || 137 | (position === 'bottom' && rect.bottom > boundaryRect.bottom - boundaryInset)); 138 | 139 | return { 140 | rect, 141 | boundaryViolation, 142 | } as const; 143 | }; 144 | 145 | export const getNudgedPopoverRect = ( 146 | popoverRect: Rect, 147 | boundaryRect: Rect, 148 | boundaryInset: number, 149 | ): Rect => { 150 | const topBoundary = boundaryRect.top + boundaryInset; 151 | const leftBoundary = boundaryRect.left + boundaryInset; 152 | const rightBoundary = boundaryRect.right - boundaryInset; 153 | const bottomBoundary = boundaryRect.bottom - boundaryInset; 154 | 155 | let top = popoverRect.top < topBoundary ? topBoundary : popoverRect.top; 156 | top = top + popoverRect.height > bottomBoundary ? bottomBoundary - popoverRect.height : top; 157 | let left = popoverRect.left < leftBoundary ? leftBoundary : popoverRect.left; 158 | left = left + popoverRect.width > rightBoundary ? rightBoundary - popoverRect.width : left; 159 | 160 | return createRect({ ...popoverRect, top, left }); 161 | }; 162 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "rootDir": "src", 5 | "sourceMap": true, 6 | "inlineSources": true, 7 | "lib": ["esnext", "dom"], 8 | "esModuleInterop": true, 9 | "noImplicitAny": true, 10 | "module": "CommonJS", 11 | "target": "es5", 12 | "jsx": "react-jsx", 13 | "pretty": true 14 | }, 15 | "include": ["src"] 16 | } 17 | --------------------------------------------------------------------------------