UNPKG

web-ui-pack

Version:
274 lines (273 loc) 10.5 kB
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; } }