16

I'm trying to implement a dropdown which you can click outside to close. The dropdown is part of a custom date input and is encapsulated inside the input's shadow DOM.

I want to write something like:

window.addEventListener('mousedown', function (evt) {
  if (!componentNode.contains(evt.target)) {
    closeDropdown();
  }
});

however, the event is retargeted, so evt.target is always the outside the element. There are multiple shadow boundaries that the event will cross before reaching the window, so there seems to be no way of actually knowing if the user clicked inside my component or not.

Note: I'm not using polymer anywhere -- I need an answer which applies to generic shadow DOM, not a polymer specific hack.

ovangle
  • 1,991
  • 1
  • 15
  • 29
  • ...would be `window`, since I'm adding the event listener to the window. – ovangle May 22 '16 at 02:38
  • Can we have a fiddle/example ? – Rayon May 22 '16 at 02:40
  • http://stackoverflow.com/a/153047/1746830 will help! – Rayon May 22 '16 at 02:50
  • _"There are multiple shadow boundaries that the event will cross before reaching the window, so there seems to be no way of actually knowing if the user clicked inside my component or not."_ The `event.target` would be the `host` element if clicked – guest271314 May 22 '16 at 03:08

4 Answers4

9

You can try using the path property of the event object. Haven't found a actual reference for it and MDN doesn't yet have a page for it. HTML5Rocks has a small section about it in there shadow dom tutorials though. As such I do not know the compatibility of this across browsers.

Found the W3 Spec about event paths, not sure if this is meant exactly for the Event.path property or not, but it is the closest reference I could find.

If anyone knows an actual spec reference to Event.path (if the linked spec page isn't already it) feel free to edit it in.

It holds the path the event went through. It will contain elements that are in a shadow dom. The first element in the list ( path[0] ) should be the element that was actually clicked on. Note you will need to call contains from the shadow dom reference, eg shadowRoot.contains(e.path[0]) or some sub element within your shadow dom.

Demo: Click menu to expand, clicking anywhere except on the menu items will close menu.

var host = document.querySelector('#host');
var root = host.createShadowRoot();
d = document.createElement("div");
d.id = "shadowdiv";

d.innerHTML = `
  <div id="menu">
    <div class="menu-item menu-toggle">Menu</div>
    <div class="menu-item">Item 1</div>
    <div class="menu-item">Item 2</div>
    <div class="menu-item">Item 3</div>
  </div>
  <div id="other">Other shadow element</div>
`;
var menuToggle = d.querySelector(".menu-toggle");
var menu = d.querySelector("#menu");
menuToggle.addEventListener("click",function(e){
  menu.classList.toggle("active");
});
root.appendChild(d)

//Use document instead of window
document.addEventListener("click",function(e){
  if(!menu.contains(e.path[0])){
    menu.classList.remove("active");
  }
});
#host::shadow #menu{
  height:24px;
  width:150px;
  transition:height 1s;
  overflow:hidden;
  background:black;
  color:white;
}
#host::shadow #menu.active {
  height:300px;
}
#host::shadow #menu .menu-item {
  height:24px;
  text-align:center;
  line-height:24px;
}

#host::shadow #other {
  position:absolute;
  right:100px;
  top:0px;
  background:yellow;
  width:100px;
  height:32px;
  font-size:12px;
  padding:4px;
}
<div id="host"></div>
Patrick Evans
  • 41,991
  • 6
  • 74
  • 87
  • Note, as `
    ` `width` is `width` of `window`, if click occurs to right of `
    – guest271314 May 22 '16 at 03:27
  • @guest271314, which is actually expected as that is the `div` that is inside the shadow root, so `shadowRoot.contains()` would return true for that. Added a log of the actual element to show it is the div that is inside – Patrick Evans May 22 '16 at 03:30
  • 1
    @guest271314, modified example to show more of what I think OP is going for. In the snippet clicking on anything except menu items will close menu. If they were to use `event.target` since it is redirected to the host you wouldn't be able to distinguish between the menu items and any other element within the shadow dom. – Patrick Evans May 22 '16 at 04:07
  • 4
    @PatrickEvans Now, you can use `Event.composedPath`. See the [`Event` interface](https://dom.spec.whatwg.org/#interface-event) in the DOM standard: *“Returns the item objects of event’s path (objects on which listeners will be invoked), except for any nodes in shadow trees of which the shadow root’s mode is "closed" that are not reachable from event’s currentTarget.”* – kleinfreund Dec 01 '18 at 09:21
6

Can't comment because of reputation, but wanted to share how it should look using composedPath. See Determine if user clicked outside shadow dom

document.addEventListener("click",function(e){
  if(!e.composedPath().includes(menu)){
    menu.classList.remove("active");
  }
});
1

The event.target of the shadowRoot would be the host element. To close a <select> element within shadowDOM if event.target is not host element you can use if (evt.target !== hostElement), then call .blur() on hostElement

var input = document.querySelector("input");
var shadow = input.createShadowRoot();
var template = document.querySelector("template");
var clone = document.importNode(template.content, true);
shadow.appendChild(clone);

window.addEventListener("mousedown", function (evt) {
  if (evt.target !== input) {
    input.blur();
  }
});
<input type="date" />
<template>
  <select>
    <option value="1999">1999</option>
    <option value="2000">2000</option>
  </select>
</template>
guest271314
  • 1
  • 15
  • 104
  • 177
  • If there is only one layer of shadow DOM, then the event will be retargeted to the host element. But if the host element is inside another element's shadow DOM, then the event will be retargeted _twice_ before reaching the event handler on the document/window. Sorry if I was unclear about that. – ovangle May 22 '16 at 04:03
0

Another option is to check the event cursor offsets against the target element:

listener(event) {
    const { top, right, bottom, left } = targetElement.getBoundingClientRect();
    const { pageX, pageY } = event;
    const isInside = pageX >= left && pageX <= right && pageY >= top && pageY <= bottom;
}
davidenke
  • 436
  • 6
  • 13