diff --git a/packages/.DS_Store b/packages/.DS_Store new file mode 100644 index 0000000..acaa2a2 Binary files /dev/null and b/packages/.DS_Store differ diff --git a/packages/behaviors/.DS_Store b/packages/behaviors/.DS_Store new file mode 100644 index 0000000..150dc98 Binary files /dev/null and b/packages/behaviors/.DS_Store differ diff --git a/packages/behaviors/hzcore-hook-click-outside-callback/.DS_Store b/packages/behaviors/hzcore-hook-click-outside-callback/.DS_Store new file mode 100644 index 0000000..87a7f7f Binary files /dev/null and b/packages/behaviors/hzcore-hook-click-outside-callback/.DS_Store differ diff --git a/packages/behaviors/hzcore-hook-click-outside-callback/CHANGELOG.md b/packages/behaviors/hzcore-hook-click-outside-callback/CHANGELOG.md new file mode 100644 index 0000000..e4d87c4 --- /dev/null +++ b/packages/behaviors/hzcore-hook-click-outside-callback/CHANGELOG.md @@ -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. diff --git a/packages/behaviors/hzcore-hook-click-outside-callback/README.mdx b/packages/behaviors/hzcore-hook-click-outside-callback/README.mdx new file mode 100644 index 0000000..eb6cbfd --- /dev/null +++ b/packages/behaviors/hzcore-hook-click-outside-callback/README.mdx @@ -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 =
This div will respond to outside clicks
+``` + +## 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' + + + {() => { + 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 ( +
+

This box is listening for clicks, inside and out. Click stuff.

+

Last clicked: {lastClick}

+ +
+ ); + }; + return ; + }} +
+ +## 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. + + + {() => { + 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 : ( +
+
+
info
+ {/* also bind passed callback to classic close button */} +
+
+
+ {props.children} +
+
) + } + + // 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 (
+ + +

+ This is an info popup, a helpful interuption, but not + so critical that we can't click out of it. +

+

+ To close it: +

+
    +
  • click the close button
  • +
  • OR just click outside the dialog
  • +
+
+
) + + } + return ; + }} +
+ +## 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`. We can meet this +requirement by specifying the element type `` when calling the +hook. + +```typescript +// receives a ref of type React.RefObject +const clickRef = useClickOutsideCallback(clickOutside); + +// then it's ok to feed that ref to a react component +const el =
...
+``` + +### 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. diff --git a/packages/behaviors/hzcore-hook-click-outside-callback/ReadmeStyles.tsx b/packages/behaviors/hzcore-hook-click-outside-callback/ReadmeStyles.tsx new file mode 100644 index 0000000..d9c18d7 --- /dev/null +++ b/packages/behaviors/hzcore-hook-click-outside-callback/ReadmeStyles.tsx @@ -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 = ``; +const closeIconSvg = ``; + +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; + } + } +`; diff --git a/packages/behaviors/hzcore-hook-click-outside-callback/__tests__/clickOutsideCallback_test.tsx b/packages/behaviors/hzcore-hook-click-outside-callback/__tests__/clickOutsideCallback_test.tsx new file mode 100644 index 0000000..2da23ea --- /dev/null +++ b/packages/behaviors/hzcore-hook-click-outside-callback/__tests__/clickOutsideCallback_test.tsx @@ -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!'); +}); diff --git a/packages/behaviors/hzcore-hook-click-outside-callback/package.json b/packages/behaviors/hzcore-hook-click-outside-callback/package.json new file mode 100644 index 0000000..5172492 --- /dev/null +++ b/packages/behaviors/hzcore-hook-click-outside-callback/package.json @@ -0,0 +1,23 @@ +{ + "name": "@hzcore/hook-click-outside-callback", + "version": "0.0.1", + "main": "cjs/index.js", + "module": "es/index.js", + "typings": "src/index.tsx", + "license": "MIT", + "private": true, + "publishConfig": { + "registry": "http://npmregistry.hzdg.com" + }, + "files": [ + "cjs", + "es", + "src", + "types", + "!**/examples", + "!**/__test*" + ], + "dependencies": { + "mini-svg-data-uri": "^1.1.3" + } +} diff --git a/packages/behaviors/hzcore-hook-click-outside-callback/src/index.tsx b/packages/behaviors/hzcore-hook-click-outside-callback/src/index.tsx new file mode 100644 index 0000000..4c54e09 --- /dev/null +++ b/packages/behaviors/hzcore-hook-click-outside-callback/src/index.tsx @@ -0,0 +1,54 @@ +import React, {useRef, useEffect} from 'react'; + +function isSameOrDescendantOf( + maybeAncestor: Node | null, + node: Node | null, +): boolean { + if (!maybeAncestor || !node) return false; + if (maybeAncestor === node) return true; + let parent = node.parentElement; + while (parent) { + if (parent === maybeAncestor) return true; + parent = parent.parentElement; + } + return false; +} + +/** + * `useClickOutsideCallback` will call the given callback function + * whenever a click event is detected 'outside' of the element currently + * referenced by the returned ref object. + * + * This is useful for behaviors like closing a popover or modal + * by clicking 'behind' or 'around' it. + */ +export default function useClickOutsideCallback( + callback?: ((event: MouseEvent) => void) | null, +): React.RefObject { + const clickOutsideRef = useRef(null); + useEffect(() => { + if (typeof callback === 'function' && typeof document !== 'undefined') { + const handleClickOutside = (event: MouseEvent): void => { + if ( + clickOutsideRef.current && + !isSameOrDescendantOf( + clickOutsideRef.current, + event.target as Node | null, + ) + ) { + callback(event); + } + }; + // options param specifies capture only ignoring events from within + // this prevents some corner cases that arise when inner elements are + // manipulated or replaced by react + document.addEventListener('click', handleClickOutside, {capture: true}); + return () => { + document.removeEventListener('click', handleClickOutside, { + capture: true, + }); + }; + } + }, [callback]); + return clickOutsideRef; +} diff --git a/yarn.lock b/yarn.lock index 5272f90..c99e111 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11769,6 +11769,11 @@ mini-html-webpack-plugin@^0.2.3: dependencies: webpack-sources "^1.1.0" +mini-svg-data-uri@^1.1.3: + version "1.1.3" + resolved "http://npmregistry.hzdg.com/mini-svg-data-uri/-/mini-svg-data-uri-1.1.3.tgz#9759ee5f4d89a4b724d089ce52eab4b623bfbc88" + integrity sha512-EeKOmdzekjdPe53/GdxmUpNgDQFkNeSte6XkJmOBt4BfWL6FQ9G9RtLNh+JMjFS3LhdpSICMIkZdznjiecASHQ== + minimalistic-assert@^1.0.0, minimalistic-assert@^1.0.1: version "1.0.1" resolved "http://npmregistry.hzdg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7"