Skip to content
This repository was archived by the owner on Jul 5, 2021. It is now read-only.

Conversation

@nathanpower
Copy link

Let me know thoughts on this? No internal state, I figure most uses will be to simply close/hide the target component, so callback probably sufficient. Open to being convinced otherwise though.

@lpww
Copy link

lpww commented Feb 12, 2019

Thanks @nathanpower ! This is working well. We removed callbacks from our other hooks as the returned state was already being updated. I think for this case it does make sense because, as you say, there is no state required. @jh3y what do you think?

@@ -0,0 +1,17 @@
## useClickOutside Hook
Copy link
Collaborator

Choose a reason for hiding this comment

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

@@ -0,0 +1,17 @@
## useClickOutside Hook

The useClickOutside Hook attaches a listener which will callback the target component with the event object on any click which is not on the target component, or a child of the target component.
Copy link
Collaborator

Choose a reason for hiding this comment

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

same here

Copy link
Collaborator

@cianfoley-nearform cianfoley-nearform left a comment

Choose a reason for hiding this comment

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

Looks good to me Nathan! Nice work.

const handler = (ev) => {
let target = ev.target

if (target === el.current) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd maybe be a little less restrictive on the el parameter here.

An array of nodes might be more powerful. Accepting either single or an array. I'd then make the checks a little more thorough. Making use of .contains would be ideal 👍

This isn't optimal but something along these lines potentially. The reason for this being you might have a layout component whose children are the main content of the app. An outside click would never fire in this case. If you can pass an Array of refs, you can avoid this issue.

const handler = (ev) => {
  const target = ev.target
  let outside = false
  if (el.current.length) {
    for (const node of el.current) {
      if (!node.contains(target)) outside = true
    }
  } else if (!el.contains(target)) {
    outside = true
  }
  if (outside) onClick(ev)
}

Copy link
Author

Choose a reason for hiding this comment

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

you might have a layout component whose children are the main content of the app. An outside click would never fire in this case.

Just trying to understand this use case... what is ev.target in this scenario, and what is el?

Copy link
Contributor

Choose a reason for hiding this comment

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

For example, we have projects where the router instance is wrapped in a layout component that compromises of the app content. You'd likely use an "off" click here for a burger menu style component. But everything on the page is a child of the element. And parentElement won't detect all scenarios. It's purely to be more flexible as people will build things in all different ways.

One example could be that you have a header and side menu. When it's open, you want to close it on "off" click. But you don't want it to close when you click the header or side menu or any descendant of each.

That way I could have something like useClickOutside([header.current, sidemenu.current], handleClickOutside)

Copy link
Author

@nathanpower nathanpower Feb 12, 2019

Choose a reason for hiding this comment

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

Thanks, this makes sense. We solve this issue in a current project by having our ClickElsewhere component take an ignore array of class-names. We also ignore anything that has a data-click-elsewhere-ignore attribute. I was going to follow up with another PR that added something like this, but I think your suggestion is better.

Copy link
Author

Choose a reason for hiding this comment

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

@jh3y does a565ab2 improve matters for you?

This solves the case where we want to not call onClick when clicking on certain components.

Your earlier comments seem to also suggest another case where onClick is not called where we do want it to be, if so I'm still not sure I understand this case (sorry).

@jh3y
Copy link
Contributor

jh3y commented Feb 12, 2019

@lpww 👍

A callback makes sense here for sure. It's more declarative. I think the checks/params could be a little more powerful. I've left a comment about this.

@nathanpower
Copy link
Author

Thanks for the feedback, will update

@nathanpower
Copy link
Author

Possibly makes sense to not merge this until #105 is in, and I'll add the checks for SSR

@nathanpower
Copy link
Author

screenshot 2019-02-12 at 14 09 01

😆

jh3y
jh3y previously approved these changes Feb 13, 2019
const handler = (ev) => {
const target = ev.target
if (els.every((ref) => !ref.current.contains(target))) {
onClick(ev)
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe add the window.removeEventListener('click', handler) inside here also to clean it up on off click but not necessarily unmount 👍

Copy link
Author

Choose a reason for hiding this comment

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

Won't this remove the handler for that component on the first off-click? I think we want to keep it there until it's unmounted.

I guess we do need to check each ref exists though, as each may be unmounted, and the cleanup handler of useEffect might not be called it may not be a ref to the component that is consuming the hook. (If that makes sense).

I guess the handler should also be removed if all els are falsey.

Copy link
Author

@nathanpower nathanpower Feb 14, 2019

Choose a reason for hiding this comment

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

I guess the handler should also be removed if all els are falsey.

Actually, on second thoughts, probably shouldn't do that. There is nothing stopping a component passing an array of refs to components other than itself, and this component will expect the handler to stay around for it's own lifetime, even if all the targets are unmounted and remounted again. I'll go with the truthy check for now.

It's a bit of an edge case I guess, I imagine most usage will be the component passing its own ref.

Copy link
Contributor

@jh3y jh3y Feb 14, 2019

Choose a reason for hiding this comment

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

It will remove it you're right. And that's because the effect only runs when el changes.
This does bring up something I think we've overlooked though 🤦‍♂️
You don't want to bind the handler on every render. Because that means the off click callback is firing even when it doesn't need to.
Think of the case where you have a menu that slides in and out. You don't want the handler firing every time the user clicks the page even if the menu is closed. So maybe it requires a parameter that tells it if it can bind, then we can conditionally bind the event in the useEffect function.

Copy link
Author

Choose a reason for hiding this comment

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

You don't want to bind the handler on every render

I thought that it bound only when el changed, (as well as mount/unmount) as el is passed in the array of dependencies (last argument to useEffect). Am I wrong there?

In any case, I think we only want to bind the handler on mount, and remove on unmount, so I think I can pass an empty array as last argument to achieve that.

Passing in an empty array [] of inputs tells React that your effect doesn’t depend on any values from the component, so that effect would run only on mount and clean up on unmount; it won’t run on updates. - https://reactjs.org/docs/hooks-reference.html#useeffect

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes. Sorry I probably didn't word that right.

Currently, that handler will fire on every click as soon as the component is mounted. You don't want that.

You only want to bind when some condition is met. For example, a menu in an open state.

Copy link
Author

@nathanpower nathanpower Feb 14, 2019

Choose a reason for hiding this comment

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

That's an interesting one... should be enforce that this condition be supplied, or would you be OK with opt-in, like 09b7b01?

Here we are back to running the hook on every render, but adding/removing the listener depending on an optional predicate.

You would use this like e.g useClickOutside(ref, optionsVisible, hideOptions)

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah I think that makes sense 👍

@lpww
Copy link

lpww commented Feb 14, 2019

@nathanpower the ssr pr has been merged. @jackdclark had a sample app that he was using to test the other hooks on a server. you may be able to get a hold of his demo?

@nathanpower
Copy link
Author

If I understand correctly, because I am only referencing window inside useEffect, there is nothing to do here with regards to SSR?

Let me know if you want me to squash the commits, there are quite a few now.

@lpww
Copy link

lpww commented Feb 15, 2019

Oh yes, good point. I think you're right that it should work with SSR! Any chance we can make the boolean into an options object? We've followed that pattern in some of our other hooks so I think it makes sense to keep that consistency here.

lpww
lpww previously approved these changes Feb 19, 2019
@lpww lpww merged commit 79c7d35 into nearform:master Feb 19, 2019
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants