Created
March 29, 2022 09:40
-
-
Save janwirth/7428b8bdc8f309eb56f70506c5eefcbd to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* 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; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* 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]); | |
} | |
}; | |
}; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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