Jake Vanderwerf
7 days ago 46d681c6b825d21b3f698d793c4e630c687d90ad
assets/js/concise/UtilityFunctions.js
@@ -152,6 +152,118 @@
   return false;
}
/**
 * Load and instantiate HTML templates as lightweight components
 */
class TemplateRegistry {
   constructor() {
      this.templates = new Map();     // name -> <template>
      this.definitions = new Map();   // name -> component definition
   }
   /**
    * Collect all <template class="name"> elements
    */
   registerAll(root = document) {
      root.querySelectorAll('template').forEach(tpl => {
         tpl.classList.forEach(name => {
            if (!this.templates.has(name)) {
               this.templates.set(name, tpl);
            }
         });
      });
   }
   /**
    * Define component behavior
    */
   define(name, definition = {}, context = null) {
      this.definitions.set(name, {
         refs: definition.refs || null,
         manyRefs: definition.manyRefs || null,
         setup: definition.setup || null,
         context: context
      });
   }
   /**
    * Create a component instance
    */
   create(name, data = {}) {
      const tpl = this.templates.get(name);
      if (!tpl) {
         console.warn(`[TemplateRegistry] Template "${name}" not found`);
         return null;
      }
      const element = tpl.content.cloneNode(true).firstElementChild;
      if (!element) return null;
      const def = this.definitions.get(name);
      const refs = def?.refs
         ? this.#collectRefs(element, def.refs)
         : {};
      const manyRefs = def?.manyRefs
         ? this.#collectRefs(element, def.manyRefs, false)
         : {};
      def?.setup?.({
         el: element,
         refs,
         manyRefs,
         data
      });
      return element;
   }
   /**
    * Resolve refs declared in component definition
    */
   #collectRefs(root, refMap, single = true) {
      const refs = {};
      for (const [key, value] of Object.entries(refMap)) {
         let selector;
         let required = false;
         if (typeof value === 'string') {
            selector = value;
         } else {
            selector = value.selector;
            required = !!value.required;
         }
         const found = (single) ? root.querySelector(selector) : root.querySelectorAll(selector);
         if (required) {
            if (single && !found) {
               console.warn(`[TemplateRegistry] Required ref "${key}" not found: ${selector}`);
            }
            if (!single && found.length === 0) {
               console.warn(`[TemplateRegistry] Required manyRef "${key}" not found: ${selector}`);
            }
         }
         refs[key] = single ? found : Array.from(found);
      }
      return refs;
   }
}
window.jvbTemplates = new TemplateRegistry();
document.addEventListener('DOMContentLoaded', () => {
   window.jvbTemplates.registerAll();
});
/**
 * Gets a clone of an icon element if it exists for efficient DOM manipulation
 * @param icon
@@ -283,6 +395,64 @@
   };
}
window.chunkIt = async function(items, renderCallback, placementCallback, size = 10) {
   const chunks = [];
   for (let i = 0; i <items.length; i += size) {
      chunks.push(items.slice(i, i + size));
   }
   for (const chunk of chunks) {
      const fragment = document.createDocumentFragment();
      chunk.forEach(item => {
         const element = renderCallback(item);
         if (element) fragment.append(element);
      });
      placementCallback(fragment);
      await new Promise(resolve => requestAnimationFrame(resolve));
   }
}
window.prefixInput = function(input, prefix, wrapper = null, replace = false, name = false) {
   if (!input) {
      console.warn('prefixInput called with null/undefined input');
      return;
   }
   // console.log('Prefixing input: ', input);
   // console.log('With prefix: ', prefix);
   // console.log('Wrapper: ', wrapper);
   const oldId = input.id;
   const newId = replace ? prefix : `${prefix}${input.name}`;
   // console.log('Old ID: ', oldId);
   // console.log('New ID: ', newId);
   // Search for label within wrapper if provided, otherwise use existing logic
   let label = null;
   if (wrapper) {
      // Most reliable: search within wrapper by old ID
      label = wrapper.querySelector(`label[for="${oldId}"]`);
   } else if (input.labels && input.labels.length > 0) {
      // Fallback to input.labels if no wrapper provided
      label = input.labels[0];
   } else if (input.previousElementSibling?.tagName === 'LABEL') {
      label = input.previousElementSibling;
   } else if (input.nextElementSibling?.tagName === 'LABEL') {
      label = input.nextElementSibling;
   } else {
      // Final fallback: search up the tree
      label = input.closest('[data-field]')?.querySelector(`label[for="${oldId}"]`);
   }
   if (label) {
      label.htmlFor = newId;
   }
   input.id = newId;
   if (name) {
      input.name = newId;
   }
}
/**
 * Makes first letter uppercase
 * @param string
@@ -389,17 +559,23 @@
 * @returns {Promise<unknown>}
 */
window.typeText = function(container, text, speed = 50) {
   container.classList.add('typeText');
   return new Promise((resolve) => {
      // Cancel any existing animation on this container
      if (container._typeInterval) {
         clearInterval(container._typeInterval);
         delete container._typeInterval;
      }
      let index = 0;
      container.textContent = '';
      const interval = setInterval(() => {
      container._typeInterval = setInterval(() => {
         if (index < text.length) {
            container.textContent += text.charAt(index);
            index++;
         } else {
            clearInterval(interval);
            clearInterval(container._typeInterval);
            delete container._typeInterval;
            resolve();
         }
      }, speed);
@@ -407,22 +583,29 @@
}
/**
 * Erases text like a keyboard would. TODO: erase a set word from existing text
 * Erases text like a keyboard would.
 * @param container
 * @param speed
 * @returns {Promise<unknown>}
 */
window.eraseText = function(container, speed = 10) {
   return new Promise((resolve) => {
      // Cancel any existing animation on this container
      if (container._eraseInterval) {
         clearInterval(container._eraseInterval);
         delete container._eraseInterval;
      }
      let text = container.textContent;
      let index = text.length;
      const interval = setInterval(() => {
      container._eraseInterval = setInterval(() => {
         if (index > 0) {
            index--;
            container.textContent = text.substring(0, index);
         } else {
            clearInterval(interval);
            clearInterval(container._eraseInterval);
            delete container._eraseInterval;
            resolve();
         }
      }, speed);
@@ -440,31 +623,47 @@
 * @returns {Function} - Call this function to stop the loop
 */
window.typeLoop = function(container, text, typeSpeed = 50, eraseSpeed = 10, pauseAfterType = 1000, pauseAfterErase = 250) {
   // Generate unique key for this container
   const containerId = container.id || container.dataset.typeKey || `type-${Date.now()}`;
   if (!container.dataset.typeKey) {
      container.dataset.typeKey = containerId;
   }
   // Stop any existing loop immediately
   if (container._stopTyping) {
      container._stopTyping();
   }
   let isRunning = true;
   async function loop() {
      while (isRunning) {
         // Type the text
         await window.typeText(container, text, typeSpeed);
         // Wait 1 second
         if (!isRunning) break;
         await new Promise(resolve => setTimeout(resolve, pauseAfterType));
         // Erase the text
         if (!isRunning) break;
         await window.eraseText(container, eraseSpeed);
         // Wait 0.25 seconds before next iteration
         if (!isRunning) break;
         await new Promise(resolve => setTimeout(resolve, pauseAfterErase));
      }
   }
   // Start the loop
   loop();
   // Return a function to stop the loop
   return function stopLoop() {
   const stopLoop = function() {
      isRunning = false;
      if (container._typeInterval) {
         clearInterval(container._typeInterval);
         delete container._typeInterval;
      }
      if (container._eraseInterval) {
         clearInterval(container._eraseInterval);
         delete container._eraseInterval;
      }
   };
   container._stopTyping = stopLoop;
   loop(); // Start immediately
   return stopLoop;
};
window.toCamelCase = function (string) {
@@ -684,23 +883,26 @@
 * @param {HTMLElement|null} parent
 * @returns {object}
 */
window.uiFromSelectors = function(selectors, parent = null) {
window.uiFromSelectors = function(selectors, parent = null, all = false) {
   let ui = {};
   for (let [key, selector] of Object.entries(selectors)) {
      if (typeof selector === 'object') {
         ui[key] = window.uiFromSelectors(selector, parent);
      }else {
         if (!parent) {
            ui[key] = document.querySelector(selector);
            ui[key] = (all) ? document.querySelectorAll(selector) : document.querySelector(selector);
         } else {
            ui[key] = parent.querySelector(selector);
            ui[key] = (all) ? parent.querySelectorAll(selector) : parent.querySelector(selector);
         }
      }
   }
   return ui;
}
window.sleep = async function (ms = 50) {
   return new Promise(resolve => setTimeout(resolve, ms));
};
class DebouncedActions {
   constructor() {
      this.timeouts = new Map();
@@ -790,15 +992,113 @@
   { passive: true }
);
window.previousBGSize = 'Small';
window.bgSizes = {
   Small: 500,
   Med: 768,
   Large: 1024
};
window.bgObserver = new IntersectionObserver((entries) => {
      entries.forEach(entry => {
         if (entry.isIntersecting) {
            let newSize = entry.target.dataset[`bg${window.previousBGSize}`];
            entry.target.style.backgroundImage = `url(${newSize})`;
            entry.target.dataset.bgImg = window.previousBGSize;
            window.bgObserver.unobserve(entry.target);
         }
      })
   },
   {
      root: null,
      rootMargin: '0px 0px -100px 0px',
      threshold: 0
   });
function updateBG() {
   let current = window.innerWidth;
   let newWidth = getBGWidth(current);
   if (newWidth) {
      window.previousBGSize = newWidth;
      document.querySelectorAll('[data-bg-img]:not([data-bg-img="'+window.previousBGSize+'"])').forEach(img => {
         window.bgObserver.observe(img);
      });
   }
}
function getBGWidth(width) {
   let prev = window.previousBGSize;
   let check = {
      Small: ['Med','Large'],
      Med: ['Large'],
      Large: false
   };
   if (!check[prev]) {
      return false;
   }
   let next = 'Small';
   check[prev].forEach(w => {
      if (width => window.bgSizes[w]) {
         next = w;
      }
   });
   return next;
}
updateBG();
// Debounced resize to recalc scrollable height
window.addEventListener('resize', () => {
   window.debouncer.schedule('recalc-max-scroll', () => {
      updateMaxScroll();
      updateScrollProgress(window.scrollY || docEl.scrollTop || 0);
   }, 20);
   window.debouncer.schedule('bg-resize', () => {
      updateBG();
   });
});
// Initial setup
updateMaxScroll();
updateScrollProgress(lastY);
window.decodeHTMLEntities = function(text) {
   if (!window.decodeHelper) {
      window.decodeHelper = document.createElement('textarea');
   }
   window.decodeHelper.innerHTML = text;
   return window.decodeHelper.value;
}
window.focusNextElement = function() {
   //add all elements we want to include in our selection
   var focussableElements =
      'a:not([disabled]), button:not([disabled]), input[type=text]:not([disabled]), [tabindex]:not([disabled]):not([tabindex="-1"])';
   if (document.activeElement && document.activeElement.form) {
      var focussable = Array.prototype.filter.call(
         document.activeElement.form.querySelectorAll(focussableElements),
         function (element) {
            //check for visibility while always include the current activeElement
            return (
               element.offsetWidth > 0 ||
               element.offsetHeight > 0 ||
               element === document.activeElement
            );
         }
      );
      var index = focussable.indexOf(document.activeElement);
      if (index > -1) {
         var nextElement = focussable[index + 1] || focussable[0];
         nextElement.focus();
      }
   }
}