@gitlab/ui
Version:
GitLab UI Components
150 lines (145 loc) • 5.01 kB
JavaScript
/**
* Map<HTMLElement, { callback: Function, eventTypes: Array<string> }>
*/
const callbacks = new Map();
const click = 'click';
const focusin = 'focusin';
const supportedEventTypes = [click, focusin];
const defaultEventType = click;
/**
* A Set to keep track of currently active event types.
* This ensures that event listeners are only added for the event types that are in use.
*
* @type {Set<string>}
*/
const activeEventTypes = new Set();
let lastMousedown = null;
const globalListener = event => {
callbacks.forEach((_ref, element) => {
let {
callback,
eventTypes
} = _ref;
const originalEvent = event.type === click ? lastMousedown || event : event;
if (
// Ignore events that aren't targeted outside the element
element.contains(originalEvent.target) ||
// Ignore events that aren't the specified types for this element
!eventTypes.includes(event.type)) {
return;
}
try {
callback(event);
} catch (e) {
if (process.env.NODE_ENV !== 'production') {
// eslint-disable-next-line no-console
console.error(e);
}
}
});
if (event.type === click) {
lastMousedown = null;
}
};
// We need to listen for mouse events because text selection fires click event only when selection ends.
// This means that the click event target could differ from the element where it originally started.
// As example: if we use mouse events we could guarantee that selecting text within a dropdown won't close it.
const onMousedown = event => {
lastMousedown = event;
};
const startListening = eventTypes => {
eventTypes.forEach(eventType => {
if (!activeEventTypes.has(eventType)) {
// Listening to mousedown events, ensures that a text selection doesn't trigger the
// GlOutsideDirective 'click' callback if the selection started within the target element.
if (eventType === click) {
document.addEventListener('mousedown', onMousedown);
}
// Added { capture: true } to all event types to prevent the behavior discussed in https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/1686#note_412545027
// Ensures the event listener handles the event in the capturing phase, avoiding issues encountered previously.
// Cannot be tested with Jest or Cypress, but can be tested with Playwright in the future: https://gitlab.com/gitlab-org/gitlab-ui/-/merge_requests/4272#note_1947425384
document.addEventListener(eventType, globalListener, {
capture: true
});
activeEventTypes.add(eventType);
}
});
lastMousedown = null;
};
const stopListening = eventTypesToUnbind => {
eventTypesToUnbind.forEach(eventType => {
if (activeEventTypes.has(eventType)) {
if ([...callbacks.values()].every(_ref2 => {
let {
eventTypes
} = _ref2;
return !eventTypes.includes(eventType);
})) {
document.removeEventListener(eventType, globalListener);
activeEventTypes.delete(eventType);
}
}
});
if (eventTypesToUnbind.includes(click) && !activeEventTypes.has(click)) {
document.removeEventListener('mousedown', onMousedown);
}
};
function parseBinding(_ref3) {
let {
arg,
value,
modifiers
} = _ref3;
const modifiersList = Object.keys(modifiers);
if (process.env.NODE_ENV !== 'production') {
if (typeof value !== 'function') {
throw new Error(`[GlOutsideDirective] Value must be a function; got ${typeof value}!`);
}
if (typeof arg !== 'undefined') {
throw new Error(`[GlOutsideDirective] Arguments are not supported. Consider using modifiers instead.`);
}
const unsupportedModifiers = modifiersList.filter(modifier => !supportedEventTypes.includes(modifier));
if (unsupportedModifiers.length > 0) {
throw new Error(`[GlOutsideDirective] Cannot bind ${unsupportedModifiers.join(', ')} events; supported event types are: ${supportedEventTypes.join(', ')}`);
}
}
return {
callback: value,
eventTypes: modifiersList.length > 0 ? modifiersList : [defaultEventType]
};
}
const bind = (el, bindings) => {
const {
callback,
eventTypes
} = parseBinding(bindings);
if (callbacks.has(el)) {
// This element is already bound. This is possible if two components, which
// share the same root node, (i.e., one is a higher-order component
// wrapping another) _both_ have this directive applied.
//
// Because Vue binds directives in the direction towards the root, only the
// deepest instance of this directive will be bound.
//
// A future iteration may add support for binding all instances on a given
// element.
return;
}
callbacks.set(el, {
callback,
eventTypes
});
startListening(eventTypes);
};
const unbind = el => {
const entry = callbacks.get(el);
if (entry) {
callbacks.delete(el);
stopListening(entry.eventTypes);
}
};
const OutsideDirective = {
bind,
unbind
};
export { OutsideDirective };