budibase/packages/bbui/src/Actions/click_outside.ts

182 lines
5.1 KiB
TypeScript

type ClickOutsideCallback = (event: MouseEvent) => void | undefined
interface ClickOutsideOpts {
callback?: ClickOutsideCallback
anchor?: HTMLElement
}
interface Handler {
id: number
element: HTMLElement
anchor: HTMLElement
callback?: ClickOutsideCallback
}
// These class names will never trigger a callback if clicked, no matter what
const ignoredClasses = [
".download-js-link",
".spectrum-Menu",
".date-time-popover",
]
// These class names will only trigger a callback when clicked if the registered
// component is not nested inside them. For example, clicking inside a modal
// will not close the modal, or clicking inside a popover will not close the
// popover.
const conditionallyIgnoredClasses = [
".spectrum-Underlay",
".drawer-wrapper",
".spectrum-Popover",
]
let clickHandlers: Handler[] = []
let candidateTarget: HTMLElement | undefined
// Processes a "click outside" event and invokes callbacks if our source element
// is valid
const handleClick = (e: MouseEvent) => {
const target = (e.target || e.relatedTarget) as HTMLElement
// Ignore click if this is an ignored class
if (target.closest('[data-ignore-click-outside="true"]')) {
return
}
for (let className of ignoredClasses) {
if (target.closest(className)) {
return
}
}
// Process handlers
clickHandlers.forEach(handler => {
// Check that the click isn't inside the target
if (handler.element.contains(target)) {
return
}
// Ignore clicks for certain classes unless we're nested inside them
for (let className of conditionallyIgnoredClasses) {
const sourceInside = handler.anchor.closest(className) != null
const clickInside = target.closest(className) != null
if (clickInside && !sourceInside) {
return
}
}
handler.callback?.(e)
})
}
// On mouse up we only trigger a "click outside" callback if we targetted the
// same element that we did on mouse down. This fixes all sorts of issues where
// we get annoying callbacks firing when we drag to select text.
const handleMouseUp = (e: MouseEvent) => {
if (candidateTarget === e.target) {
handleClick(e)
}
candidateTarget = undefined
}
// On mouse down we store which element was targetted for comparison later
const handleMouseDown = (e: MouseEvent) => {
// Only handle the primary mouse button here.
// We handle context menu (right click) events in another handler.
if (e.button !== 0) {
return
}
candidateTarget = e.target as HTMLElement
// Clear any previous listeners in case of multiple down events, and register
// a single mouse up listener
document.removeEventListener("click", handleMouseUp)
document.addEventListener("click", handleMouseUp, true)
}
// Handle iframe clicks by detecting a loss of focus on the main window
const handleBlur = () => {
if (
document.activeElement &&
["IFRAME", "BODY"].includes(document.activeElement.tagName)
) {
handleClick(
new MouseEvent("click", { relatedTarget: document.activeElement })
)
}
}
// Global singleton listeners for our events
document.addEventListener("mousedown", handleMouseDown)
document.addEventListener("contextmenu", handleClick)
window.addEventListener("blur", handleBlur)
/**
* Adds or updates a click handler
*/
const updateHandler = (
id: number,
element: HTMLElement,
anchor: HTMLElement,
callback: ClickOutsideCallback | undefined
) => {
let existingHandler = clickHandlers.find(x => x.id === id)
if (!existingHandler) {
clickHandlers.push({ id, element, anchor, callback })
} else {
existingHandler.callback = callback
}
}
/**
* Removes a click handler
*/
const removeHandler = (id: number) => {
clickHandlers = clickHandlers.filter(x => x.id !== id)
}
/**
* Svelte action to apply a click outside handler for a certain element.
* opts.anchor is an optional param specifying the real root source of the
* component being observed. This is required for things like popovers, where
* the element using the clickoutside action is the popover, but the popover is
* rendered at the root of the DOM somewhere, whereas the popover anchor is the
* element we actually want to consider when determining the source component.
*/
export default (
element: HTMLElement,
opts?: ClickOutsideOpts | ClickOutsideCallback
) => {
const id = Math.random()
const isCallback = (
opts?: ClickOutsideOpts | ClickOutsideCallback
): opts is ClickOutsideCallback => {
return typeof opts === "function"
}
const isOpts = (
opts?: ClickOutsideOpts | ClickOutsideCallback
): opts is ClickOutsideOpts => {
return opts != null && typeof opts === "object"
}
const update = (newOpts?: ClickOutsideOpts | ClickOutsideCallback) => {
let callback: ClickOutsideCallback | undefined
let anchor = element
if (isCallback(newOpts)) {
callback = newOpts
} else if (isOpts(newOpts)) {
callback = newOpts.callback
if (newOpts.anchor) {
anchor = newOpts.anchor
}
}
updateHandler(id, element, anchor, callback)
}
update(opts)
return {
update,
destroy: () => removeHandler(id),
}
}