0

Say I have multiple dropdowns or elements on the page that all use this directive I've used called closable. This calls an expression passed in if the element clicked is outside of the element using the directive.

However the expected behaviour is that if I click an element on page i.e. another dropdown with a directive it should get that click event path compare them to the existing one and if they don't match or aren't contained in the elemement it should close it.

What actually happens is the click event is never registered, it just initalizes another directive and for some reason that click event is lost.

The only time the click event is registerd is if I click on something that doesn't have the directive.

Vue.directive ( 'closable', {
    inserted: ( el, binding, vnode ) => {
        // assign event to the element
        el.clickOutsideEvent = function ( event ) {
            console.log ( {el, event} );
            // here we check if the click event is outside the element and it's children
            if ( !( el == event.path[0] || el.contains ( event.path[0] ) ) ) {
                // if clicked outside, call the provided method
                vnode.context[binding.expression] ( event );
            }
        };
        // register click and touch events
        document.body.addEventListener ( 'click', el.clickOutsideEvent );
        document.body.addEventListener ( 'touchstart', el.clickOutsideEvent );
    },
    unbind: function ( el ) {
        // unregister click and touch events before the element is unmounted
        document.body.removeEventListener ( 'click', el.clickOutsideEvent );
        document.body.removeEventListener ( 'touchstart', el.clickOutsideEvent );
    },
    stopProp ( event ) {
        event.stopPropagation ();
    },
} );
2
  • Guess your trying to create sorta click-outside directive? Commented Apr 26, 2022 at 1:44
  • Yeah. The code above works perfectly as long as you don't click on another element that has a closable directive Commented Apr 26, 2022 at 12:41

2 Answers 2

0

UPDATE

Here is another variant for a v-click-outside directive - locally, right inside your component:

  directives:
    {
      clickOutside:
        {
          bind(elem, binding, vnode)
          {
            elem.clickOutsideEvent = function(evt)
            {
              if (elem !== evt.target && !elem.contains(evt.target)) vnode.context[binding.expression](evt);
            };
            document.body.addEventListener('click', elem.clickOutsideEvent);
          },
          unbind(elem)
          {
            document.body.removeEventListener('click', elem.clickOutsideEvent);
          }
        }
    },

You can try this implementation:

import Vue from 'vue'

const HAS_WINDOWS = typeof window !== 'undefined';
const HAS_NAVIGATOR = typeof navigator !== 'undefined';
const IS_TOUCH = HAS_WINDOWS && ('ontouchstart' in window || (HAS_NAVIGATOR && navigator.msMaxTouchPoints > 0));
const EVENTS = IS_TOUCH ? ['touchstart'] : ['click'];
const IDENTITY = (item) => item;

const directive = {
  instances: [],
};

function processDirectiveArguments (bindingValue)
{
  const isFunction = typeof bindingValue === 'function';
  if (!isFunction && typeof bindingValue !== 'object')
  {
    throw new Error('v-click-outside: Binding value must be a function or an object')
  }

  return {
    handler: isFunction ? bindingValue : bindingValue.handler,
    middleware: bindingValue.middleware || IDENTITY,
    events: bindingValue.events || EVENTS,
    isActive: !(bindingValue.isActive === false),
  }
}

function onEvent ({ el, event, handler, middleware })
{
  const isClickOutside = event.target !== el && !el.contains(event.target);

  if (!isClickOutside)
  {
    return
  }

  if (middleware(event, el))
  {
    handler(event, el)
  }
}

function createInstance ({ el, events, handler, middleware })
{
  return {
    el,
    eventHandlers: events.map((eventName) => ({
      event: eventName,
      handler: (event) => onEvent({
        event,
        el,
        handler,
        middleware
      }),
    })),
  }
}

function removeInstance (el)
{
  const instanceIndex = directive.instances.findIndex((instance) => instance.el === el);
  if (instanceIndex === -1)
  {
    // Note: This can happen when active status changes from false to false
    return
  }

  const instance = directive.instances[instanceIndex];

  instance.eventHandlers.forEach(({ event, handler }) =>
    document.removeEventListener(event, handler)
  );

  directive.instances.splice(instanceIndex, 1)
}

function bind (el, { value })
{
  const { events, handler, middleware, isActive } = processDirectiveArguments(value);

  if (!isActive)
  {
    return
  }

  const instance = createInstance({
    el,
    events,
    handler,
    middleware
  });

  instance.eventHandlers.forEach(({ event, handler }) =>
    setTimeout(() => document.addEventListener(event, handler), 0)
  );
  directive.instances.push(instance)
}

function update (el, { value, oldValue })
{
  if (JSON.stringify(value) === JSON.stringify(oldValue))
  {
    return
  }

  const { events, handler, middleware, isActive } = processDirectiveArguments(value);

  if (!isActive)
  {
    removeInstance(el);
    return
  }

  let instance = directive.instances.find((instance) => instance.el === el);

  if (instance)
  {
    instance.eventHandlers.forEach(({ event, handler }) =>
      document.removeEventListener(event, handler)
    );
    instance.eventHandlers = events.map((eventName) => ({
      event: eventName,
      handler: (event) => onEvent({
        event,
        el,
        handler,
        middleware
      }),
    }))
  }
  else
  {
    instance = createInstance({
      el,
      events,
      handler,
      middleware
    });
    directive.instances.push(instance)
  }

  instance.eventHandlers.forEach(({ event, handler }) =>
    setTimeout(() => document.addEventListener(event, handler), 0)
  )
}

directive.bind = bind;
directive.update = update;
directive.unbind = removeInstance;

Vue.directive('click-outside', directive); 
Sign up to request clarification or add additional context in comments.

3 Comments

same issue.. I'll see if I can make a jsfiddle as an example
Now having a nightmare with getting a directive to work just using the Vue library
thanks for your update. the function you posted works but it doesn't resolve the issue of clicking another element with the click-outside directive. I've posted my solution below if you want to check it out
0

So aftering trying to get the events to register I just decided to go about this a diffrent way.

Everytime a closable directive is inserted it calls any previous expressions that where open before,and then adds the new expression handler to a variable called prevNodes so next time a closable directive is inserted it calls that expression

let prevNodes = [];
Vue.directive ( 'closable', {
    inserted: ( el, binding, vnode ) => {

        console.log ( {prevNodes} );

        prevNodes.forEach ( item => {
            //console.log ( item );
            const {vnode, binding} = item;
            vnode.context[binding.expression] ();
        } );


        // assign event to the element
        el.clickOutsideEvent = function ( event ) {
            // here we check if the click event is outside the element and it's children
            if ( !( el == event.path[0] || el.contains ( event.path[0] ) ) ) {
                // if clicked outside, call the provided method
                vnode.context[binding.expression] ( event );
            }
        };

        prevNodes.push ( {vnode, binding} );

        // register click and touch events
        document.body.addEventListener ( 'click', el.clickOutsideEvent );
        document.body.addEventListener ( 'touchstart', el.clickOutsideEvent );
    },
    unbind: function ( el, binding, vnode ) {
        const removeIndex = prevNodes.findIndex ( item => item.vnode.elm === vnode.elm );
        prevNodes.splice ( removeIndex, 1 );
        // unregister click and touch events before the element is unmounted
        document.body.removeEventListener ( 'click', el.clickOutsideEvent );
        document.body.removeEventListener ( 'touchstart', el.clickOutsideEvent );
    },
    stopProp ( event ) {
        event.stopPropagation ();
    },
} );

2 Comments

I do not understand your issue. If you have 2 buttons which open 2 popup menus - you apply the directive to each menu and provide a function which closes the corresponding menu if the user clicks outside that menu. At the same time, each of the 2 buttons has onClick handler which opens the corresponding menu. Since there is no guarantee which event will come first - the onClick handlers should open their menu with a small timeout. So you click on the left button and its menu pops up. You click on the right button - previous menu closes and the new menu pops up.
Ok... Imagine you have button A and button B. They both open a menu that has the directive... This is the scenario, you click button A to open the menu, then you click on button B to open that menu. The expected behavior would be that the first menu from button A should close as you clicked outside of it. But what ends up happening is when you click button B it binds an new instance of the directive and for some reason the previous eventListen doesn't seem to pick that up... Soo that's why I added the prevNodes.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.