0

I have a small React 16 menu panel that is running in a Rails 6 application. When the panel is open, I want to close it and stop propagation, if the user clicks outside the panel. (I don't want an accidental click outside the menu to take the user to another page.)

The general approach is quite well understood - here's one question that describes it. In brief:

  1. Using native JavaScript (i.e. document.addEventListener('click', callback)) inside of the React component, listen for the click event anywhere.
  2. When that click event fires, check if the element (i.e. event.target) is within the div of the React menu.
  3. If the click was outside of the React menu, close the menu, and call event.preventDefault() and event.stopPropagation() to stop accidental navigation.

This all works fine in my simple test case. (Here's a fiddle, just to prove it works.)

However, it fails in my nested Rails application, if the user clicks on an anchor tag, because Rails's Unobtrusive JavaScript adds listeners to all anchor tags that contain the data-method attribute. The unobtrusive JS then hijacks the event, and injects an HTML form into the body. The target of the click event becomes an input of that new form. You can see the form being dynamically created when I click on a link in the panel here:

enter image description here

This means that the event.target property is the input in this dynamically-injected form, and not the actual a tag the user clicked on.

So my question is: How can I get the actual element the user clicked on, which triggered the creation of the form.

Stuff I've tried/thought of:

  • Not using data-method. This works for the links in my menu. But then I cannot do POST events like logout, and any links created with Rails's helper methods.
  • Listening for onmousedown instead of onclick. In this case, the event's target is the correct element, but calling preventDefault() doesn't actually prevent the click.
  • Using forms inside my menu component instead of anchor tags. This way, I can do a POST for the logout action, without having to use the data-method attribute which introduces the problem described here. And it just works here, since if I click a link in the Rails app, I know it's outside the menu. But it's a hacky workaround, and it doesn't give me flexibility if I want to listen to some other clicks in the future.
antun
  • 2,038
  • 2
  • 22
  • 34
  • You don't have to make the entire menu a form. You can just use `button_to` to make the "links" that perform non-get requests into discrete forms. Is it really more hacky then Rails UJS adding a behavior that links where never intended to have? – max Nov 02 '20 at 16:23
  • @max so `button_to` skips the dynamic form creation that UJS behavior that `link_to` with a `method:` brings? – antun Nov 02 '20 at 16:31
  • 1
    Yes. It creates an actual form element that just contains a single button and hidden inputs for `_method` and the anti-CSRF token. It works even if JS is turned off completely as you're just using the browsers native behavior of sending POST requests together with [Rack::MethodOverride](https://www.rubydoc.info/gems/rack/Rack/MethodOverride) for PATCH and DELETE. – max Nov 02 '20 at 16:37
  • Ah yes, I just tried it quickly, and it does do exactly what you describe. The problem is that it means I have to replace any use of `link_to` in my site. My ideal solution would be a JavaScript-only approach that lets me see what the user *actually* clicked on before Rails UJS hacked it. – antun Nov 02 '20 at 16:39
  • You can always just redefine `Rails.handleMethod`. `let oldHandleMethod = Rails.handleMethod; Rails.handleMethod = function(e) { console.log('Hello world'); return oldHandleMethod(e) };`. The code itself is actually pretty simple. https://github.com/rails/rails/tree/master/actionview/app/assets/javascripts/rails-ujs – max Nov 02 '20 at 20:36
  • I haven't done something like this in a while but I would probably do the same thing you do with a modal - cover the viewport with a transparent element (a mask) that blocks clicks outside the modal and bind an event handler to the mask that hides the modal. – max Nov 02 '20 at 20:41

0 Answers0