Skip to content
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

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added packages/.DS_Store
Binary file not shown.
Binary file added packages/behaviors/.DS_Store
Binary file not shown.
Binary file not shown.
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.
214 changes: 214 additions & 0 deletions packages/behaviors/hzcore-hook-click-outside-callback/README.mdx
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

Copy link
Contributor

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>

## 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.
117 changes: 117 additions & 0 deletions packages/behaviors/hzcore-hook-click-outside-callback/ReadmeStyles.tsx
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!');
Copy link
Contributor

Choose a reason for hiding this comment

The 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!

Copy link
Author

Choose a reason for hiding this comment

The 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.

});
23 changes: 23 additions & 0 deletions packages/behaviors/hzcore-hook-click-outside-callback/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@hzcore/hook-click-outside-callback",
"version": "0.0.1",
Copy link
Contributor

@lettertwo lettertwo Mar 13, 2020

Choose a reason for hiding this comment

The 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.

Copy link
Contributor

Choose a reason for hiding this comment

The 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
"version": "0.0.1",
"version": "1.0.0-alpha.0",

This results in lerna rolling us up to 1.0.0 automatically on first publish.

"main": "cjs/index.js",
"module": "es/index.js",
"typings": "src/index.tsx",
"license": "MIT",
"private": true,
Copy link
Contributor

Choose a reason for hiding this comment

The 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
"private": true,

"publishConfig": {
"registry": "http://npmregistry.hzdg.com"
},
"files": [
"cjs",
"es",
"src",
"types",
"!**/examples",
"!**/__test*"
],
"dependencies": {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks likemini-svg-data-uri is only used in the example, which I think means it should be in "devDependencies":

Suggested change
"dependencies": {
"devDependencies": {

"mini-svg-data-uri": "^1.1.3"
}
Copy link
Contributor

@lettertwo lettertwo Mar 13, 2020

Choose a reason for hiding this comment

The 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
}
},
"peerDependencies": {
"react": "^16.8.0"
},
"dependencies": {
"@babel/runtime": "^7.4.4"
}

Rationale

React 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.

}
Loading