From 62e2419eff4eefd6c74029d229afb591f6b8fcc3 Mon Sep 17 00:00:00 2001 From: adam tolley Date: Tue, 3 Mar 2020 18:03:48 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20progress=20on=20adding=202u=20us?= =?UTF-8?q?eOutsideClickCallback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🚧 (@hzcore/hook-click-outside-callback) more docz, spiffy stylez pretty much done with this one, two snazzy examples and some prose, but need a little more work documenting proper typescript usage 🚧 (@hzcore/hook-click-outside-callback) polish wip fighting commitizen --- packages/.DS_Store | Bin 0 -> 8196 bytes packages/behaviors/.DS_Store | Bin 0 -> 6148 bytes .../.DS_Store | Bin 0 -> 6148 bytes .../CHANGELOG.md | 4 + .../README.mdx | 214 ++++++++++++++++++ .../ReadmeStyles.tsx | 117 ++++++++++ .../__tests__/clickOutsideCallback_test.tsx | 7 + .../package.json | 23 ++ .../src/index.tsx | 54 +++++ yarn.lock | 5 + 10 files changed, 424 insertions(+) create mode 100644 packages/.DS_Store create mode 100644 packages/behaviors/.DS_Store create mode 100644 packages/behaviors/hzcore-hook-click-outside-callback/.DS_Store create mode 100644 packages/behaviors/hzcore-hook-click-outside-callback/CHANGELOG.md create mode 100644 packages/behaviors/hzcore-hook-click-outside-callback/README.mdx create mode 100644 packages/behaviors/hzcore-hook-click-outside-callback/ReadmeStyles.tsx create mode 100644 packages/behaviors/hzcore-hook-click-outside-callback/__tests__/clickOutsideCallback_test.tsx create mode 100644 packages/behaviors/hzcore-hook-click-outside-callback/package.json create mode 100644 packages/behaviors/hzcore-hook-click-outside-callback/src/index.tsx diff --git a/packages/.DS_Store b/packages/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..acaa2a227170bb648052318b60e6ad0e1e93ee78 GIT binary patch literal 8196 zcmeHMTTC2P7(U+u?M#`L0kOsEW*3ptRGKanA+>6^+>}BCx=^64h1s34j1Dt&XLe}| zG-#r!F^&4xi%}nZF?e~=Rv*13-rgiJZQ_fXczIBh`qIQ0|1)Qnlv~mVV`@4lIp;g) zznnAw&v(x385m<|&FKw{RWZgSsvOl)YE~#*&+D=h2_(gYAb-a4%w!JBGB-WB%sLc= z83;2FW+2Q!n1L_@H$w*Koz081%6ngE!#2!7n1Nd|1N?l5Q{|Wpa8{sybWr180SNgO zz%SINdw}l~4=@?vtU%wD<`lOF1g;3K7!dBHk8^XvWPq~*g*$_AX9!kCa6^H=I{C$Y zbB3hAunjX1W?*>+`1X-mni*`2Y1iN1-HEj2q)GcldcrbYFOm2bk)q;~HEShFDwWnt zkEh0*$&{ygIk#2I9^#`u%^OPx{eH)>7W!KHfN4yQsFiJw?P;cMWPN8-H^}7hplR#Q zWUG_2b=RLKB^0tOOY(?XH8r&}(NrIANi@#X$ER9a67}(>oy{{dvb3qT;lZxa@e?Pf zre{u_6VV|!Az0axXwR=4s^#X}M5vMZLa0{?p~~w@mG#km-97u2-VwDTPf_$vc&1~! z2aK#sIHH!h6nR(1vHP+H*AHeKD>rVtBWig@vobkL^NhWgl`>BnBr9_>S;w;a9M|O2 z3=et6agXbksfG@^PS$JlCmT`KoNGL0&|GHTXr~$7A(NKmjwXFg#XS-Bkba=)#O8}k)acWl1% zu4r_rbzzpFT-rRAGrbdPjUwMb&_<5%iFRBwL+|H zi`L7kCiT1Y)MAPqN`tJra{p7r0O8uCG)t;34_I2-u!LxrvRhKS<-xqjxDZwtUnxX={p|VQ1MKdyidYm)IxlYc|iWupim)><@O8{mHH&0tpq^ zj2hHp3+j+S19qYbd$AAQ=)n*)q@lyWlW^f7k4a49Sv-f+cpfj}EY9HqUcno98}Hy< ze1MPeF+Rm-n8$bc2|wc({EDmiD^e0!7YTm6M+&U(D}*61Qc1YN1}D}B8$5DhW!A?! zAAY1u>Arbce|ZC^`fZiBZ`>5G-L|7?*F&ufgjm9X>$&LDPw>mZ8@5b=d}Pn5JQ(3RH36iNQKNc<)HhJDX|Bl=!re`5_wQH5$E?sl|b z57D+A9q2?a9z_cM7{C#X5`i;B;c+-PhAh!|8mBOer|}G4z!@U(OL!UQiNvqsHN1{D zF^9K^&KGfs==>qk`70vxWn2kJS|D%FV)EjLmC2h=Wo^r`kCL|Fx!5rr-AD$Cgi#eg zLONFe{(mD=g*IUZ!VLURGl1guRC_Bu{A!0OziY>-j#A}?+sz8}U8wP|5tg({qHmJ)@uF%$Y?s! literal 0 HcmV?d00001 diff --git a/packages/behaviors/.DS_Store b/packages/behaviors/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..150dc98a61acd0bb84c6a5e08d381a2acdf6850b GIT binary patch literal 6148 zcmeHK&2AGh5FV!iys8j6Akm{$iCd5oewq`~DwG2^q^;lpsNKy*WW}9Qo-Lqq zbIfT<4`@afKaszI0nXhnGImN6{gpUf5~& zs+cYhdv~ANs!5A#I(NZEK84HM*F}}v<=8H&yms>>ejth>9Ub;|R;%IOuy39`9Ig9i zH5v{2=JBJw^*Yk`?hl{7IQsbMboF`t<*Uz-!pH06PQVR(gK;h78!pWhxuXYs=VL%x8&luqI!Jsk50b7sm=zwx10I-X0 zC$PDe;20Ay4%m9c2t@f%pbu4{#85sQ{U-Ru0b7qgoKz@2R9IPs3PriqF~4!{q+*Yv zg#lsUX9l?TgY@|RfByUW-#LjA284lslL6J;Pxi;yTew>ZUl~nwf7th6l5?nXQmS`{XbWK4;G2rWm5f9;3b6Xgk{t zA+7+xCFlsu<1PVgB!IneN(6)Olmb)gb;a {/* ...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"