Jake Vanderwerf
27 mins ago 3baf3d2545ba6ece6b74a64c0def59bd0774cf54
assets/js/concise/Popup.js
@@ -1,79 +1,159 @@
class Popup {
   constructor(config = {}) {
      this.config = {
         toggle: document.querySelector('[data-toggles]'),
         popup: document.querySelector('.jvb-pop'),
         name: 'Popup',
         onOpen: () =>{},
         onClose: () =>{},
         onEscape: () =>{},
         ... config
      };
   constructor() {
      if (Popup.instance) {
         return Popup.instance;
      }
      Popup.instance = this;
      this.a11y = window.jvbA11y;
      this.toggleID = this.config.toggle.id === '' ? '.'+this.config.toggle.className.split(' ').join('.') : this.config.toggle.id;
      this.popups = new Map();
      this.activePopup = null;
      this.initListeners();
   }
   initListeners()
   {
      this.clickHandler = this.handleClick.bind(this);
      this.keyHandler = this.handleEscape.bind(this);
      document.addEventListener('click', this.handleToggle.bind(this));
   }
   handleToggle(e) {
      if (e.target === this.config.toggle || e.target.closest(this.toggleID)){
         this.togglePopup();
      }
   }
   handleClick(e) {
      if (!this.config.popup.contains(e.target)
         && e.target !== this.config.toggle) {
         this.closePopup();
      } else if (window.targetCheck(e, '.close')) {
         this.closePopup();
      }
   }
   togglePopup() {
      if (!this.config.popup.classList.contains('expanded')) {
         this.openPopup();
      } else {
         this.closePopup();
      }
   }
   openPopup(message = `Opened ${this.config.name}`) {
      this.config.popup.classList.add('expanded');
      this.config.toggle.classList.add('expanded');
      this.config.toggle.title = `Hide ${this.config.name}`;
      this.config.toggle.ariaExpanded = true;
      this.config.toggle.querySelector('span').textContent = `Close ${this.config.name}`;
      this.a11y.announce(message);
      this.config.onOpen();
      document.addEventListener('keydown', this.keyHandler);
      document.addEventListener('click', this.clickHandler);
   }
   closePopup(message = `Closed ${this.config.name}`) {
      this.config.popup.classList.remove('expanded');
      this.config.toggle.classList.remove('expanded');
      this.config.toggle.title = `Show ${this.config.name}`;
      this.config.toggle.ariaExpanded = false;
      this.config.toggle.querySelector('span').textContent = '';
      this.a11y.announce(message);
      this.config.onClose();
      document.removeEventListener('keydown', this.keyHandler);
      document.removeEventListener('click', this.clickHandler);
   }
   handleEscape(e) {
      if (e.key === 'Escape') {
         this.closePopup(`Closed ${this.config.name} with escape key`);
         this.config.onEscape();
   /**
    * Register a new popup
    * @param {Object} config - { toggle, popup, name, onOpen, onClose, onEscape }
    * @returns {Object} API for this popup
    */
   registerPopup(config = {}) {
      const popup = config.popup;
      const toggle = config.toggle;
      if (!popup || !toggle) {
         console.warn('Popup registration requires both popup and toggle elements');
         return null;
      }
      const id = toggle.id || '.' + toggle.className.split(' ').join('.');
      const entry = {
         id,
         toggle,
         popup,
         name: config.name || 'Popup',
         onOpen: config.onOpen || (() => {}),
         onClose: config.onClose || (() => {}),
         onEscape: config.onEscape || (() => {}),
         isOpen: false
      };
      this.popups.set(id, entry);
      return this.getPopupAPI(id);
   }
   getPopupAPI(id) {
      return {
         openPopup: (message) => this.openPopup(id, message),
         closePopup: (message) => this.closePopup(id, message),
         togglePopup: () => this.togglePopup(id),
         isOpen: () => this.popups.get(id)?.isOpen || false,
         destroy: () => this.popups.delete(id)
      };
   }
   /**
    * Single document-level click handler for all popups
    */
   handleClick(e) {
      // Check if click is on any registered toggle
      for (const [id, entry] of this.popups) {
         if (e.target === entry.toggle || e.target.closest(id)) {
            this.togglePopup(id);
            return;
         }
      }
      // If a popup is active, close it on outside click
      if (this.activePopup) {
         const entry = this.popups.get(this.activePopup);
         if (entry && !entry.popup.contains(e.target)) {
            this.closePopup(this.activePopup);
         } else if (entry && window.targetCheck(e, '.close')) {
            this.closePopup(this.activePopup);
         }
      }
   }
   handleEscape(e) {
      if (e.key === 'Escape' && this.activePopup) {
         const entry = this.popups.get(this.activePopup);
         this.closePopup(this.activePopup, `Closed ${entry.name} with escape key`);
         entry.onEscape();
      }
   }
   togglePopup(id) {
      const entry = this.popups.get(id);
      if (!entry) return;
      if (entry.isOpen) {
         this.closePopup(id);
      } else {
         this.openPopup(id);
      }
   }
   openPopup(id, message) {
      const entry = this.popups.get(id);
      if (!entry) return;
      // Close any currently active popup first
      if (this.activePopup && this.activePopup !== id) {
         this.closePopup(this.activePopup);
      }
      entry.isOpen = true;
      this.activePopup = id;
      entry.popup.classList.add('expanded');
      entry.toggle.classList.add('expanded');
      entry.toggle.title = `Hide ${entry.name}`;
      entry.toggle.ariaExpanded = true;
      entry.toggle.querySelector('span').textContent = `Close ${entry.name}`;
      this.a11y.announce(message || `Opened ${entry.name}`);
      entry.onOpen();
      document.addEventListener('keydown', this.keyHandler);
   }
   closePopup(id, message) {
      const entry = this.popups.get(id);
      if (!entry) return;
      entry.isOpen = false;
      if (this.activePopup === id) {
         this.activePopup = null;
      }
      entry.popup.classList.remove('expanded');
      entry.toggle.classList.remove('expanded');
      entry.toggle.title = `Show ${entry.name}`;
      entry.toggle.ariaExpanded = false;
      entry.toggle.querySelector('span').textContent = '';
      this.a11y.announce(message || `Closed ${entry.name}`);
      entry.onClose();
      // Only remove keydown if no popups are active
      if (!this.activePopup) {
         document.removeEventListener('keydown', this.keyHandler);
      }
   }
   destroy() {
      document.removeEventListener('click', this.clickHandler);
      document.removeEventListener('keydown', this.keyHandler);
      this.popups.clear();
      this.activePopup = null;
   }
}
window.jvbPopup = Popup;
document.addEventListener('DOMContentLoaded', function () {
   window.jvbPopup = new Popup();
});