-
Notifications
You must be signed in to change notification settings - Fork 0
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Adding useClickOutsideCallback hook from 2u #50
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
# Change Log | ||
|
||
All notable changes to this project will be documented in this file. | ||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,214 @@ | ||
--- | ||
name: useClickOutsideCallback | ||
menu: Hooks | ||
route: /use-click-outside-callback | ||
--- | ||
|
||
import {Playground, Props} from 'docz'; | ||
import useClickOutsideCallback from './src'; | ||
import {useState} from 'react'; | ||
import Styles from './ReadmeStyles.tsx'; | ||
|
||
# useClickOutsideCallback | ||
|
||
## Installation | ||
|
||
```shell | ||
yarn add @hzcore/hook-click-outside-callback | ||
``` | ||
|
||
## Usage | ||
|
||
```js | ||
import useClickOutsideCallback from '@hzcore/hook-click-outside-callback'; | ||
``` | ||
|
||
### To use the hook: | ||
|
||
1. Create a callback function that accepts a `MouseEvent` | ||
2. Pass that callback to `useClickOutsideCallback` to get a React `ref` | ||
3. Add the ref to the target element/component | ||
|
||
```typescript | ||
const onClickOutside = (event: MouseEvent) => {/* ...do something */}; | ||
const clickRef = useClickOutsideCallback(onClickOutside); | ||
const el = <div ref={clickRef}>This div will respond to outside clicks</div> | ||
``` | ||
|
||
## Simple Example | ||
|
||
Check out the example's code to see the above steps in context. | ||
|
||
Here two event listeners are set up: one for clicks inside the element, and one | ||
for outside clicks. The text in the box will update to show the source of the | ||
last click (anywhere on this page) as 'Inside' or 'Outside' | ||
|
||
<Playground> | ||
{() => { | ||
const Demo = () => { | ||
// const [clickCount, setClickCount] = useState(0); | ||
const [lastClick, setLastClick] = useState('(none)'); | ||
|
||
const clickInside = (e) => { | ||
setLastClick('Inside'); | ||
e.preventDefault(); | ||
}; | ||
|
||
const clickOutside = (e) => { | ||
setLastClick('Outside'); | ||
e.preventDefault(); | ||
}; | ||
|
||
// Pass in callback (clickOutside) and get reference to affix to an element | ||
const clickRef = useClickOutsideCallback(clickOutside) | ||
|
||
return ( | ||
<div ref={clickRef} onClick={clickInside} className="clickBox"> | ||
<p>This box is listening for clicks, inside and out. Click stuff.</p> | ||
<p>Last clicked: {lastClick}</p> | ||
<button>This is a button.</button> | ||
</div> | ||
); | ||
}; | ||
return <Styles><Demo /></Styles>; | ||
}} | ||
</Playground> | ||
|
||
## Composing Components | ||
|
||
We can use this hook to compose higher level components like a pop-up info | ||
dialog. In the following example the `InfoPopup` component is built to accept | ||
`isOpen` and `onClose` props, delegating its state management. | ||
|
||
The component encapsulates the usage of the hook, and presents a popup dialog | ||
that will call the passed callback when the user clicks either outside of the | ||
dialog or on the close button. | ||
|
||
<Playground> | ||
{() => { | ||
const Demo = () => { | ||
|
||
// example component that accepts isOpen and onClose props | ||
const InfoPopup = (props) => { | ||
// bind passed callback to outside click | ||
const clickRef = useClickOutsideCallback(props.onClose) | ||
// only return body when isOpen == true | ||
return !props.isOpen ? null : ( | ||
<div ref={clickRef} className="info-popup"> | ||
<div className="info-popup-top"> | ||
<div className="info-popup-title">info</div> | ||
{/* also bind passed callback to classic close button */} | ||
<div className="info-popup-close" onClick={props.onClose}></div> | ||
</div> | ||
<div className="info-popup-content"> | ||
{props.children} | ||
</div> | ||
</div>) | ||
} | ||
|
||
// setup events to hide and show an instance of the popup component | ||
const [showingPopup, setShowingPopup] = useState(false) | ||
const showPopup = (e) => { | ||
setShowingPopup(true) | ||
e.preventDefault() | ||
} | ||
const closePopup = (e) => { | ||
setShowingPopup(false) | ||
e.preventDefault() | ||
} | ||
|
||
// render a button to trigger the popup, and the popup itself, | ||
// passing in content and the onClose callback | ||
return (<div className="stage"> | ||
<button onClick={showPopup}>Show info modal</button> | ||
<InfoPopup isOpen={showingPopup} onClose={closePopup}> | ||
<p> | ||
This is an info popup, a helpful interuption, but not | ||
so critical that we can't click out of it. | ||
</p> | ||
<p> | ||
To close it: | ||
</p> | ||
<ul> | ||
<li>click the close button</li> | ||
<li>OR just click outside the dialog</li> | ||
</ul> | ||
</InfoPopup> | ||
</div>) | ||
|
||
} | ||
return <Styles><Demo /></Styles>; | ||
}} | ||
</Playground> | ||
|
||
## Nullable callback parameter | ||
|
||
When composing a component it may be useful to toggle outside click handling | ||
via some prop or state. One way to do this is to pass `null` as the callback | ||
parameter to the `useClickOutsideCallback` hook. | ||
|
||
When `null` is passed as the callback, no event listeners are registerd, but a | ||
ref is still returned and can be safely attached without further logic. | ||
|
||
As an example, if our component had a prop called `shouldCloseOnClick` we could | ||
toggle the behavior with the following code. In this case, the event listener is | ||
only registered if `shouldCloseOnClick` is truthy | ||
|
||
```js | ||
useClickOutsideCallback(shouldCloseOnClick ? handler : null) | ||
``` | ||
|
||
## Typescript Considerations | ||
|
||
When using typescript react elements expect properly typed props. Here's how to | ||
keep React and Typescript happy: | ||
|
||
### the ref element | ||
|
||
If we are attaching the returned `ref` to a div element, react will expect that | ||
ref to be of the type `React.RefObject<HTMLDivElement>`. We can meet this | ||
requirement by specifying the element type `<HTMLDivElement>` when calling the | ||
hook. | ||
|
||
```typescript | ||
// receives a ref of type React.RefObject<HTMLDivElement> | ||
const clickRef = useClickOutsideCallback<HTMLDivElement>(clickOutside); | ||
|
||
// then it's ok to feed that ref to a react component | ||
const el = <div ref={clickRef} className="clickBox">...</div> | ||
``` | ||
|
||
### The callback | ||
|
||
React generally expects to work with React event objects, but the | ||
`useClickOutsideCallback` requires a standard (non-react) MouseEvent. This is | ||
because the outside click monitoring relies on a document level click handler | ||
that is propogated back down to the target element. | ||
|
||
As a result, the callback should be of type `MouseEvent`. However if the | ||
callback might also be used as a standard react callback (see above modal example) | ||
then we also need it to work with `React.MouseEvent`. In that case, we can implement | ||
our call back like this: | ||
|
||
```typescript | ||
const clickOutsideOrClose = (e: MouseEvent | React.MouseEvent) => { | ||
closeTheDialog(); | ||
e.stopPropagation(); | ||
}; | ||
``` | ||
|
||
This will work for both cases. If the handler is only passed to the hook we can | ||
omit the union with the latter type: | ||
|
||
```typescript | ||
const clickOutsideOrClose = (e: MouseEvent | React.MouseEvent) => {/*...*/} | ||
``` | ||
|
||
## Event weirdness. | ||
|
||
React consumes standard browser events at the document level before repackaging | ||
them as React events. As a result, when using outside clicks to close a dialog, | ||
it is possible for the dialog to close, but still handle latent react click | ||
events that may have happened inside that dialog. Do not rely on the closing | ||
the dialog to cancel all pending listeners; use `stopPropogation` to keep your | ||
events in check. |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import styled from 'styled-components'; | ||
import miniSvg from 'mini-svg-data-uri'; | ||
|
||
const colors = { | ||
primary: '#f38230', | ||
bg_alt1: '#eee', | ||
}; | ||
|
||
const infoIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="16" viewBox="0 0 14 16"><path fill="" fill-rule="evenodd" d="M6.3 5.69a.942.942 0 01-.28-.7c0-.28.09-.52.28-.7.19-.18.42-.28.7-.28.28 0 .52.09.7.28.18.19.28.42.28.7 0 .28-.09.52-.28.7a1 1 0 01-.7.3c-.28 0-.52-.11-.7-.3zM8 7.99c-.02-.25-.11-.48-.31-.69-.2-.19-.42-.3-.69-.31H6c-.27.02-.48.13-.69.31-.2.2-.3.44-.31.69h1v3c.02.27.11.5.31.69.2.2.42.31.69.31h1c.27 0 .48-.11.69-.31.2-.19.3-.42.31-.69H8V7.98v.01zM7 2.3c-3.14 0-5.7 2.54-5.7 5.68 0 3.14 2.56 5.7 5.7 5.7s5.7-2.55 5.7-5.7c0-3.15-2.56-5.69-5.7-5.69v.01zM7 .98c3.86 0 7 3.14 7 7s-3.14 7-7 7-7-3.12-7-7 3.14-7 7-7z"/></svg>`; | ||
const closeIconSvg = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="16" viewBox="0 0 12 16"><path fill="" fill-rule="evenodd" d="M7.48 8l3.75 3.75-1.48 1.48L6 9.48l-3.75 3.75-1.48-1.48L4.52 8 .77 4.25l1.48-1.48L6 6.52l3.75-3.75 1.48 1.48L7.48 8z"/></svg>`; | ||
|
||
const svgUrl = (svg: string, fill: string = '#000'): string => { | ||
const coloredSvg = svg.replace('fill=""', `fill="${fill}"`); | ||
const url = miniSvg(coloredSvg); | ||
console.log(url); | ||
return `url("${url}")`; | ||
}; | ||
|
||
export default styled.div` | ||
display: flex; | ||
justify-content: center; | ||
align-items: center; | ||
|
||
.clickBox { | ||
margin: 1.2em; | ||
background: ${colors.bg_alt1}; | ||
padding: 0.8em 1.2em; | ||
border: dashed 2px ${colors.primary}; | ||
} | ||
|
||
.stage { | ||
min-height: 20em; | ||
width: 100%; | ||
position: relative; | ||
display: flex; | ||
align-items: center; | ||
justify-content: center; | ||
padding: 1.2em; | ||
} | ||
|
||
.info-popup { | ||
position: absolute; | ||
top: 20%; | ||
right: 33%; | ||
left: 33%; | ||
min-height: 33%; | ||
|
||
display: flex; | ||
flex-direction: column; | ||
justify-items: flex-start; | ||
align-items: stretch; | ||
|
||
z-index: 10; | ||
} | ||
|
||
.info-popup-top { | ||
background-color: ${colors.primary}; | ||
border-radius: 0.5em 0.5em 0 0; | ||
width: 100%; | ||
height: 1.5em; | ||
position: relative; | ||
margin-bottom: -2px; | ||
} | ||
|
||
.info-popup-title { | ||
color: #fff; | ||
text-align: center; | ||
font-weight: bold; | ||
} | ||
|
||
.info-popup-close { | ||
background-color: #fff; | ||
border-radius: 3em; | ||
position: absolute; | ||
right: 0.3em; | ||
top: 0.3em; | ||
height: 1em; | ||
width: 1em; | ||
&:hover { | ||
transform: scale(1.2); | ||
} | ||
&::after { | ||
content: 'close'; | ||
color: transparent; | ||
position: absolute; | ||
top: 0; | ||
right: 0; | ||
bottom: 0; | ||
left: 0; | ||
background-image: ${svgUrl(closeIconSvg, colors.primary)}; | ||
background-repeat: no-repeat; | ||
background-position: center; | ||
overflow: hidden; | ||
} | ||
} | ||
|
||
.off { | ||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='16' viewBox='0 0 12 16'%3E%3Cpath fill='%23999' fill-rule='evenodd' d='M7.48 8l3.75 3.75-1.48 1.48L6 9.48l-3.75 3.75-1.48-1.48L4.52 8 .77 4.25l1.48-1.48L6 6.52l3.75-3.75 1.48 1.48L7.48 8z'/%3E%3C/svg%3E"); | ||
background-repeat: no-repeat; | ||
background-position: center; | ||
} | ||
|
||
.info-popup-content { | ||
padding: 0.5em; | ||
border: 2px solid ${colors.primary}; | ||
background-color: ${colors.bg_alt1}; | ||
background-image: ${svgUrl(infoIconSvg, '#fff')}; | ||
background-size: contain; | ||
background-repeat: no-repeat; | ||
background-position: center; | ||
border-radius: 0 0 0.5em 0.5em; | ||
p, | ||
ul { | ||
margin: 0.5em 0; | ||
} | ||
} | ||
`; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
/* eslint-env jest, browser */ | ||
import clickOutsideCallback from '../src'; | ||
|
||
test('clickOutsideCallback is implemented', () => { | ||
expect(() => clickOutsideCallback()).not.toThrow(); | ||
throw new Error('implement clickOutsideCallback and write some tests!'); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We'll have to make this test pass before we publish. Nice to have: some more tests! There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't want to merge without tests, unless we have a pressing need for this component. |
||
}); |
Original file line number | Diff line number | Diff line change | ||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,23 @@ | ||||||||||||||||||
{ | ||||||||||||||||||
"name": "@hzcore/hook-click-outside-callback", | ||||||||||||||||||
"version": "0.0.1", | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe a short description from the README could be added here in a "description" field. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One thing i've been doing to prep new packages for their first publish is to set the version to a major alpha, like:
Suggested change
This results in lerna rolling us up to |
||||||||||||||||||
"main": "cjs/index.js", | ||||||||||||||||||
"module": "es/index.js", | ||||||||||||||||||
"typings": "src/index.tsx", | ||||||||||||||||||
"license": "MIT", | ||||||||||||||||||
"private": true, | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We'll need to make the package 'public', even though we'll be publishing to a private registry:
Suggested change
|
||||||||||||||||||
"publishConfig": { | ||||||||||||||||||
"registry": "http://npmregistry.hzdg.com" | ||||||||||||||||||
}, | ||||||||||||||||||
"files": [ | ||||||||||||||||||
"cjs", | ||||||||||||||||||
"es", | ||||||||||||||||||
"src", | ||||||||||||||||||
"types", | ||||||||||||||||||
"!**/examples", | ||||||||||||||||||
"!**/__test*" | ||||||||||||||||||
], | ||||||||||||||||||
"dependencies": { | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Looks like
Suggested change
|
||||||||||||||||||
"mini-svg-data-uri": "^1.1.3" | ||||||||||||||||||
} | ||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should add react to "peerDependencies", and "@babel/runtime" to dependencies, like:
Suggested change
RationaleReact as a peer dependency because we depend on react, but we don't want to implicitly choose the version of React for the user's application by forcing install (nor do we want to risk introducing duplicate versions of React to the dependency tree!) @babel/runtime as a dependency because we use @babel/plugin-transform-runtime when building hzcore packages, which will introduce the runtime as a dependency as part of the transform. |
||||||||||||||||||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like to add a little description sentence here, like
A react hook for <insert reason to use this package here>