web-ui-pack
Version:
Web package with UI elements
274 lines (273 loc) • 10.5 kB
JavaScript
import onFocusGot from "../helpers/onFocusGot";
import onFocusLost from "../helpers/onFocusLost";
import { focusFirst, onEvent } from "../indexHelpers";
export default class PopupListener {
options;
onOpen;
onClose;
static $defaults = {
openCase: 8,
hoverOpenTimeout: 200,
hoverCloseTimeout: 500,
};
constructor(options, onOpen, onClose) {
this.options = options;
this.onOpen = onOpen;
this.onClose = onClose;
this.options = { ...PopupListener.$defaults, ...options };
const t = this.options.target;
if (!(t instanceof HTMLElement)) {
throw new Error("WUP-Popup. Target is required");
}
if (!t.isConnected) {
throw new Error("WUP-Popup. Target must be appended to layout");
}
this.listen();
}
openedEl = null;
isOpening;
isClosing;
async open(openCase, e) {
if (this.openedEl || this.isOpening || !this.options.target.isConnected) {
return;
}
this.isOpening = true;
this.#lastActive = document.activeElement;
this.isOpening = new Promise(async (res, rej) => {
try {
this.openedEl = await this.onOpen(openCase, e);
res();
}
catch (err) {
rej(err);
}
}).finally(() => (this.isOpening = false));
await this.isOpening;
if (this.openedEl) {
setTimeout(() => this.openedEl && this.listenOnOpen());
}
}
async close(closeCase, e) {
this.isOpening && (await this.isOpening);
if (!this.openedEl || this.isClosing) {
return;
}
this.disposeOnClose();
const was = this.openedEl;
this.openedEl = null;
try {
this.isClosing = true;
const r = onEvent(was, "focusin", ({ relatedTarget }) => {
this.focusBack(relatedTarget);
});
const isOk = await this.onClose.call(was, closeCase, e || null);
r();
if (isOk) {
this.#openedByHover = false;
const isMeFocused = this.isMe(document.activeElement, was);
const needFocusBack = isMeFocused || closeCase === 4;
needFocusBack && this.focusBack(null);
this.#lastActive = undefined;
}
if (!isOk && !this.openedEl) {
this.openedEl = was;
this.listenOnOpen();
}
}
catch (error) {
Promise.reject(error);
}
this.isClosing = false;
}
focusBack(relatedTarget) {
this.#preventFocusEvent = true;
const t = this.options.target;
const el = relatedTarget ||
(t === this.#lastActive || this.includes.call(t, this.#lastActive) ? this.#lastActive : t);
el !== document.activeElement && focusFirst(el);
this.#preventFocusEvent = false;
}
includes(el) {
return el instanceof Node && this.contains(el);
}
isMe(el, openedEl = this.openedEl) {
return openedEl === el || this.includes.call(openedEl, el);
}
#preventFocusEvent;
#preventClickAfterFocus;
#wasOutsideClick;
handleEventsDocument(e) {
if (e.defaultPrevented) {
return;
}
switch (e.type) {
case "focusin":
if (!this.isMe(e.target)) {
this.#lastActive = e.target;
}
break;
case "click":
{
if (this.#preventClickAfterFocus) {
return;
}
const isTarget = this.includes.call(this.options.target, e.target);
if (isTarget) {
return;
}
if (this.isMe(e.target)) {
this.close(4, e);
}
else {
this.close(3, e);
this.#wasOutsideClick = true;
setTimeout(() => (this.#wasOutsideClick = false), 50);
}
}
break;
case "keydown":
if (e.key === "Escape") {
this.close(8, e);
}
break;
}
}
#openedByHover;
#debounceClick;
#hoverTimeout;
handleEvents(ev) {
if (ev.defaultPrevented) {
return;
}
switch (ev.type) {
case "click":
{
const e = ev;
if (!e.pageX) {
this.#preventClickAfterFocus = false;
}
const skip = this.#preventClickAfterFocus ||
this.#debounceClick ||
this.#wasOutsideClick ||
this.#openedByHover ||
e.button;
if (skip) {
return;
}
this.openedEl
? this.close(this.isMe(e.target) ? 4 : 5, e)
: this.open(8, e);
this.#debounceClick = setTimeout(() => (this.#debounceClick = undefined), 10);
}
break;
case "mouseenter":
this.#hoverTimeout && clearTimeout(this.#hoverTimeout);
if (!this.openedEl) {
this.#hoverTimeout = setTimeout(() => {
this.#openedByHover && clearTimeout(this.#openedByHover);
this.#openedByHover = setTimeout(() => (this.#openedByHover = false), 300);
this.open(2, ev);
}, this.options.hoverOpenTimeout);
}
break;
case "mouseleave":
this.#hoverTimeout && clearTimeout(this.#hoverTimeout);
if (this.openedEl) {
this.#hoverTimeout = setTimeout(() => {
this.#openedByHover && clearTimeout(this.#openedByHover);
this.#openedByHover = false;
this.close(1, ev);
}, this.options.hoverCloseTimeout);
}
break;
case "focusin":
if (!this.openedEl && !this.#preventFocusEvent && !this.#openedByHover) {
this.#preventClickAfterFocus = !!(this.options.openCase & 8);
this.open(4, ev).then(() => {
if (this.#preventClickAfterFocus) {
document.addEventListener("pointerdown", () => (this.#preventClickAfterFocus = false), {
once: true,
capture: true,
passive: true,
});
}
});
}
break;
case "focusout":
if (this.openedEl) {
const e = ev;
const next = e.relatedTarget || document.activeElement;
const isToMe = this.isMe(next);
const t = this.options.target;
const isToTarget = t === next || this.includes.call(t, next);
!isToMe && !isToTarget && this.close(2, e);
}
break;
}
}
#lastActive;
#defargs = { passive: true };
#disposeFocus;
#disposeFocusLost;
#disposeFocusLost2;
listen() {
this.handleEventsDocument = this.handleEventsDocument.bind(this);
this.handleEvents = this.handleEvents.bind(this);
const { target: t, openCase } = this.options;
if (openCase & 8) {
t.addEventListener("click", this.handleEvents, this.#defargs);
}
if (openCase & 2) {
t.addEventListener("mouseenter", this.handleEvents, this.#defargs);
t.addEventListener("mouseleave", this.handleEvents, this.#defargs);
}
if (openCase & 4) {
this.#disposeFocus = onFocusGot(t, this.handleEvents, {
debounceMs: this.options.focusDebounceMs,
});
const a = document.activeElement;
if (a === t || t.contains(a)) {
this.handleEvents(new FocusEvent("focusin"));
this.#preventClickAfterFocus = false;
}
}
}
listenOnOpen() {
const el = this.openedEl;
document.addEventListener("focusin", this.handleEventsDocument, this.#defargs);
document.addEventListener("keydown", this.handleEventsDocument, this.#defargs);
const { target: t, openCase } = this.options;
if (openCase & 8) {
document.addEventListener("click", this.handleEventsDocument, this.#defargs);
}
if (openCase & 2) {
el.addEventListener("mouseenter", this.handleEvents, this.#defargs);
el.addEventListener("mouseleave", this.handleEvents, this.#defargs);
}
this.#disposeFocusLost = onFocusLost(t, this.handleEvents, {
debounceMs: this.options.focusDebounceMs,
});
this.#disposeFocusLost2 = onFocusLost(el, this.handleEvents, {
debounceMs: this.options.focusDebounceMs,
});
}
disposeOnClose() {
document.removeEventListener("keydown", this.handleEventsDocument, this.#defargs);
document.removeEventListener("focusin", this.handleEventsDocument, this.#defargs);
document.removeEventListener("click", this.handleEventsDocument, this.#defargs);
this.openedEl?.removeEventListener("mouseenter", this.handleEvents, this.#defargs);
this.openedEl?.removeEventListener("mouseleave", this.handleEvents, this.#defargs);
this.#disposeFocusLost?.call(this);
this.#disposeFocusLost2?.call(this);
}
stopListen() {
this.disposeOnClose();
const t = this.options.target;
t.removeEventListener("click", this.handleEvents, this.#defargs);
t.removeEventListener("mouseenter", this.handleEvents, this.#defargs);
t.removeEventListener("mouseleave", this.handleEvents, this.#defargs);
this.#disposeFocus?.call(this);
this.openedEl = null;
}
}