Skip to content

Instantly share code, notes, and snippets.

@janwirth
Created March 29, 2022 09:40
Show Gist options
  • Save janwirth/7428b8bdc8f309eb56f70506c5eefcbd to your computer and use it in GitHub Desktop.
Save janwirth/7428b8bdc8f309eb56f70506c5eefcbd to your computer and use it in GitHub Desktop.
/* common */
.funk-dropdown-contents {
z-index: 1000;
}
funk-dropdown:not(.open) .funk-dropdown-contents {
pointer-events: none !important;
opacity: 0;
display: none;
}
funk-dropdown:not(.open) .funk-dropdown-contents * {
pointer-events: none;
}
/* base */
funk-dropdown {
user-select: none;
z-index: 200;
}
funk-dropdown.open .funk-dropdown-contents {
opacity: 1;
pointer-events: all !important;
z-index: 1000;
display: block;
}
funk-dropdown.open {
z-index: 1000;
}
funk-dropdown.fixed-z-index.fixed-z-index:not(.open) {
z-index: 0;
}
import "./Dropdown.css";
import { usePopout } from "./floating";
/**
* Dropdown module
* @module customElements/Dropdown
* @description
* Given button and content will produce a dropdown.
* @example
* <funk-dropdown>
* <div class="funk-dropdown-button">Click me</div>
* <div class="funk-dropdown-contents">Some options...</div>
* </funk-dropdown>
*/
class Dropdown extends HTMLElement {
// set up events
connectedCallback() {
this.classList.add("closed");
this.handleButtonClick = async (ev) => {
// closed
if (this.classList.contains("open")) {
this.classList.remove("open");
this.classList.add("closed");
this.cleanupPopout && this.cleanupPopout();
// opened
} else {
this.classList.add("open");
await usePopout(this, "TimezonePicker", (cleanupPopout) => {
this.cleanupPopout = cleanupPopout;
});
this.classList.remove("closed");
}
};
this.handleClickOutside = (ev) => {
this.classList.remove("open");
this.classList.add("closed");
this.cleanupPopout && this.cleanupPopout();
};
// when an item in the content was selected, close the dropdown
// exception: if the clicked item is 'insensitive', do not close the dropdown
this.handleContentClick = (ev) => {
// recursive function to detect if it's an insensitive row
const isInsensitiveRow = (el) => {
const isInsensitive = el.classList.contains("funk-dropdown-keep-open");
const isContainer = el.classList.contains("funk-dropdown-contents");
if (isInsensitive) {
return true;
} else if (isContainer) {
// we have checked everywhere but could not find a class that tells that we are in an insensitive row
return false;
} else {
// when we are not at the container yet but have not found the class we go up by one
return isInsensitiveRow(el.parentElement);
}
};
// only close if the row that was clicked is not sensitive to closing on click
if (isInsensitiveRow(ev.target)) {
return;
} else {
this.classList.remove("open");
this.classList.add("closed");
}
};
this.button = this.querySelector(".funk-dropdown-button");
if (!this.button) {
throw new Error(".funk-dropdown-button not found");
}
this.contents = this.querySelector(".funk-dropdown-contents");
if (!this.contents) {
throw new Error(".funk-dropdown-contents not found");
}
this.button = this.querySelector(".funk-dropdown-button");
this.contents = this.querySelector(".funk-dropdown-contents");
this.button.addEventListener("click", this.handleButtonClick);
this.addEventListener("clickoutside", this.handleClickOutside);
this.contents.addEventListener("click", this.handleContentClick);
}
disconnectedCallback() {
this.button.removeEventListener("click", this.handleButtonClick);
this.removeEventListener("clickoutside", this.handleClickOutside);
this.contents.removeEventListener("click", this.handleContentClick);
this.cleanupPopout && this.cleanupPopout();
}
}
customElements.define("funk-dropdown", Dropdown);
/**
* This bad boi allows for detecting events that happen outside an element!
* For this we attach an event listener to the document which catches events from anywhere.
* Then we check if the element is somewhere in the hierarchy from the document to the event target.
* If so, ignore it. If it's not in the hierarchy, the event happened outside and we want to inform the listener.
*/
export const apply = () => {
// this map is used to associate our custom handlers with those in memory of the code that is adding `outside` event listeners
// in order to find the right custom handlers when the consuming code removes their handlers again.
document.clickoutsideHandlers = new Map();
// we keep the original method here so that we can call it later
// we only use this one from now on because we do not want to produce an infinite recursion loop
const _addEventListener = HTMLElement.prototype.addEventListener;
// patch addEventListener
HTMLElement.prototype.addEventListener = function (
type,
handler,
useCapture
) {
// add new custom listener for everything that ends with `outside`
if (type.endsWith("outside")) {
// shave off the `outside` suffix
const handlerName = type.substr(0, type.length - 7);
const targetEl = this;
const customHandler = (ev) => {
// only fire if the target node is still in the DOM.
// Elm will not disconnect event listeners and thus the handler on the document is still there.
if (!this.isConnected) {
return;
}
const clickedElement = ev.target;
var elementInHierarchy = clickedElement;
while (elementInHierarchy.parentElement) {
if (elementInHierarchy == targetEl) {
return; // clicked inside
} else {
elementInHierarchy = elementInHierarchy.parentElement;
}
}
handler(ev);
};
// if the user clicks anywhere, check if the element we attached the listener to is the target or in the target's hierarchy
_addEventListener.apply(document, [handlerName, customHandler]);
document.clickoutsideHandlers.set(handler, customHandler);
} else {
_addEventListener.apply(this, [type, handler, useCapture]);
}
};
const _removeEventListener = HTMLElement.prototype.removeEventListener;
HTMLElement.prototype.removeEventListener = function (
type,
handler,
useCapture
) {
// remove new custom listener for clickoutside
if (type.endsWith("outside")) {
// shave off the `outside` suffix
const handlerName = type.substr(0, type.length - 7);
const targetEl = this;
// find the good ol' handler and remove it from the element
const customHandler = document.clickoutsideHandlers.get(handler);
_removeEventListener.apply(document, [handlerName, customHandler]);
document.clickoutsideHandlers.delete(handler);
} else {
_removeEventListener.apply(this, [type, handler, useCapture]);
}
};
};
import { computePosition, offset } from "@floating-ui/dom";
export const usePopout = (this_, name, callback) => {
// make sure element is mounted
try {
init(this_, name, callback)();
} catch (e) {
requestAnimationFrame(init(this_, name, callback));
}
};
const init = (this_, name, callback) => () => {
if (name === "TimezonePicker") {
const el = this_.querySelector(".funk-dropdown-contents");
if (el) {
// apply floating UI to break out of overflow: scroll
const button = el.previousSibling;
const tooltip = el;
const placement = "bottom-start";
const middleware = [offset(10)];
const update = async () => {
computePosition(button, tooltip, { placement, middleware }).then(
({ x, y }) => {
Object.assign(tooltip.style, {
left: `${x}px`,
top: `${y}px`,
});
}
);
};
update();
// update for each parent that can scroll
// 1. find each parent
var parents = [];
var el_ = el;
var parent = el_.parentElement;
while (el_.parentElement) {
parents = [...parents, el_];
el_ = el_.parentElement;
}
// remove not scrollables
const parentsWithScroll = parents.filter(
(p) =>
p.computedStyleMap().get("overflow").toString().indexOf("scroll") > -1
);
// add listeners
parentsWithScroll.forEach((p) => p.addEventListener("scroll", update));
window.addEventListener("resize", update);
// provide cleanup function
const cleanup = () => {
window.removeEventListener("resize", update);
parentsWithScroll.forEach((p) =>
p.removeEventListener("scroll", update)
);
};
callback(cleanup);
} else {
throw new Error("dropdown contents not found");
}
}
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment