Jake Vanderwerf
2026-01-20 7a9054bb3f033c98067b3196378311dae54c5fbf
assets/js/concise/TaxonomySelector.js
@@ -14,6 +14,7 @@
      this.activeField = null;
      this.isInitializing = true;
      this.lazyInit = false;
      this.messageText = {}
      this.init();
   }
@@ -21,6 +22,7 @@
   init() {
      this.initStore();
      this.initElements();
      this.defineTemplates();
      this.initModal();
      this.scanExistingFields();
      this.initListeners();
@@ -62,6 +64,88 @@
      this.store.subscribe(this.handleStoreEvent.bind(this));
   }
   defineTemplates() {
      const T = window.jvbTemplates;
      const terms = this;
      T.define('emptyState');
      T.define('selectedTerm', {
         refs: {
            name: '.item-name',
            btn: 'button',
         },
         setup({el, refs, manyRefs, data}) {
            el.dataset.id = data.id;
            el.dataset.taxonomy = data.taxonomy;
            if (refs.name) refs.name.textContent = data.path;
            if (refs.button) refs.button.title = `Remove ${data.name}`;
         }
      });
      T.define('termListItem', {
         refs: {
            checkbox: 'input',
            label: 'label',
            name: 'span, .term-name'
         },
         setup({el, refs, manyRefs, data}) {
            el.dataset.id = data.id;
            let field = terms.currentField();
            let isSelected = terms.selectedTerms.get(terms.activeField).has(data.id);
            let limitReached = field.limit > 0 && terms.selectedTerms.get(terms.activeField).size >= field.limit;
            if (refs.checkbox) {
               refs.checkbox.dataset.id = data.id;
               refs.checkbox.id = `${field.element.id}-${data.id}`;
               refs.checkbox.name = `${field.element.id}-${field.taxonomy}-select`;
               refs.checkbox.value = data.id;
               refs.checkbox.disabled = !isSelected && limitReached;
               refs.checkbox.checked = isSelected;
            }
            if (refs.label) {
               refs.label.htmlFor = `${field.element.id}-${data.id}`;
               refs.label.title = data.path??data.name;
               refs.label.dataset.path = data.path;
            }
            if (refs.name) {
               refs.name.textContent = data.show ? data.path : data.name;
            }
            if (data.hasChildren) {
               let temp = {
                  plural: field.plural,
                  name: data.name
               };
               const toggle = window.jvbTemplates.create('termChildrenToggle', temp);
               el.append(toggle);
            }
         }
      });
      T.define('termChildrenToggle', {
         setup({el, refs, manyRefs, data}) {
            el.ariaLabel = `View ${data.plural} nested under ${data.name}`;
         }
      });
      T.define('termBreadcrumb', {
         setup({el, refs, manyRefs, data}) {
            el.dataset.id = data.id;
            el.textContent = data.name;
            el.title = data.name;
         }
      });
      T.define('autocompleteItem', {
         setup({el, refs, manyRefs, data}) {
            el.dataset.id = data.id;
            el.textContent = data.path||data.name;
            el.title = `Select ${data.name}`;
         }
      });
   }
   /******************************************************************
    ELEMENTS
    ******************************************************************/
@@ -148,19 +232,23 @@
   }
   handleClick(e) {
      if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) {
         return;
      }
      const fieldId = this.getFieldId(e.target) || this.activeField;
      const field = this.fields.get(fieldId);
      if (!fieldId || !field) return;
      const autoComplete = window.targetCheck(e, '.item.autocomplete');
      const autocomplete = window.targetCheck(e, '.item.autocomplete');
      if (autoComplete) {
         let termId = parseInt(autoComplete.dataset.id);
      if (autocomplete) {
         let termId = parseInt(autocomplete.dataset.id);
         this.addSelected(termId, fieldId);
         this.scheduleHideDropdown(fieldId);
         this.scheduleHideDropdown(fieldId, 6000);
         if (field.ui.search) {
            field.ui.search.value = '';
         }
         return;
      }
      const toggleButton = window.targetCheck(e, this.selectors.field.toggle);
@@ -207,12 +295,14 @@
      if (pathLevel) {
         const termId = parseInt(pathLevel.dataset.id)??0;
         this.navigateTo(termId);
         return;
      }
      const dropdown = window.targetCheck(e, this.selectors.field.dropdown);
      if (dropdown) {
         // reset the timer for hiding the dropdown
         this.scheduleHideDropdown(fieldId);
         return;
      }
      const clearSearch = window.targetCheck(e, this.selectors.search.clear);
@@ -240,7 +330,7 @@
   }
   handleChange(e) {
      if (!this.container.contains(e.target)) {
      if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) {
         return;
      }
      if (!['checkbox', 'button'].includes(e.target.type)) return;
@@ -257,6 +347,9 @@
   }
   //For search in modal or field autocomplete
   handleInput(e) {
      if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) {
         return;
      }
      let fieldId = this.getFieldId(e.target)??this.activeField;
      if (!fieldId) return;
      const field = this.fields.get(fieldId);
@@ -315,9 +408,13 @@
   }
   handleFocus(e) {
      if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) {
         return;
      }
      const fieldId = this.getFieldId(e.target);
      if (!fieldId) return;
      const field = this.fields.get(fieldId);
      if (!fieldId || !field) return;
      if (!field) return;
      if (!field.hasAutocomplete && !field.hasSearch) return;
      window.debouncer.cancel(`${fieldId}-search-results`);
@@ -329,16 +426,20 @@
   //Hide autocomplete dropdown on blur
   handleBlur(e) {
      if (!this.container.contains(e.target) && !e.target.closest('[data-type="selector"], [data-field-type="selector"]')) {
         return;
      }
      const fieldId = this.getFieldId(e.target);
      if (!fieldId) return;
      const field = this.fields.get(fieldId);
      if (!fieldId || ! field) return;
      if (!field) return;
      if (!field.hasAutocomplete || this.container.open) return;
      if (e.relatedTarget && field.ui.dropdown.wrapper?.contains(e.relatedTarget)) return;
      this.scheduleHideDropdown(fieldId);
   }
   scheduleHideDropdown(fieldId){
   scheduleHideDropdown(fieldId, delay = 1500){
      const field = this.fields.get(fieldId);
      if (!field) return;
@@ -352,7 +453,7 @@
               field.ui.dropdown.wrapper.hidden = true;
            }
         },
         1500
         delay
      );
   }
@@ -411,16 +512,78 @@
      this.a11y.announce(message);
   }
   openEmpty(taxonomy, singular, plural, onComplete) {
      // Store the callback for when modal closes
      this.emptyCallback = onComplete;
      // Create a temporary "field" for bulk operations
      const bulkFieldId = `empty-${taxonomy}-${Date.now()}`;
      if (!this.fields.has(bulkFieldId)) {
         this.fields.set(bulkFieldId, {
            id: bulkFieldId,
            taxonomy: taxonomy,
            singular: singular,
            plural: plural,
            canSearch: true,
            canCreate:  false,
            hasAutocomplete: false,
            isFilter: false,
            isEmpty: true,
            limit: 0,
            ui: {},
            element: null,
            value: null,
            toggle: null,
            checked: true
         });
         this.selectedTerms.set(bulkFieldId, new Set());
      }
      this.setField(bulkFieldId);
      this.ui.modal.title.textContent = `Add to ${plural}`;
      if (this.ui.search?.container) {
         this.ui.search.container.hidden = false;
      }
      window.removeChildren(this.ui.selected);
      window.removeChildren(this.ui.terms.list);
      this.modal.handleOpen();
   }
   closeModal() {
      const field = this.fields.get(this.activeField);
      if (!field) return;
      this.updateFieldValue(this.activeField);
      this.observer.unobserve(this.ui.terms.sentinel);
      window.removeChildren(this.ui.terms.list);
      this.notify('selected-terms', {
         terms: this.selectedTerms.get(this.activeField),
         taxonomy: field.taxonomy
      });
      if (field.isEmpty  && this.emptyCallback) {
         const selectedTermIds = Array.from(this.selectedTerms.get(this.activeField) || []);
         const selectedTerms = selectedTermIds.map(id => this.store.get(id)).filter(Boolean);
         this.emptyCallback({
            taxonomy: field.taxonomy,
            termIds: selectedTermIds,
            terms: selectedTerms
         });
         // Cleanup temporary bulk field
         this.fields.delete(this.activeField);
         this.selectedTerms.delete(this.activeField);
         this.emptyCallback = null;
         this.bulkAssignmentTaxonomy = null;
      } else {
         this.notify('selected-terms', {
            terms: this.selectedTerms.get(this.activeField),
            taxonomy: field.taxonomy
         });
      }
      this.activeField = null;
@@ -464,24 +627,26 @@
      if (!field) return;
      if (this.ui.selected.querySelector(`[data-id="${termId}"]`)) return;
      const item = window.getTemplate('selectedTerm');
      if (!item) return;
      this.ui.selected.append(this.getSelectedTermUI(term));
   }
      item.dataset.id = termId;
      item.dataset.taxonomy = field.taxonomy;
      item.querySelector('.item-name').textContent = term.path;
      item.querySelector('button').title = `Remove ${term.name}`;
      this.ui.selected.append(item);
   getSelectedTermUI(term, showPath = true) {
      return window.jvbTemplates.create('selectedTerm', term);
   }
   /******************************************************************
    FIELDS
    ******************************************************************/
   scanExistingFields(container = document.body) {
      container.querySelectorAll('[data-type="selector"]').forEach(
      container.querySelectorAll('[data-type="selector"], [data-field-type="selector"]').forEach(
         selector => {
            try {
               this.registerField(selector);
               if (selector.dataset.lazy) {
                  this.lazyInit = true;
               } else {
                  // Register field if not already registered
                  // registerField will check if already registered and return early if so
                  this.registerField(selector);
               }
            } catch (error) {
               this.error.log(error, {
                  component: 'TaxonomySelector',
@@ -491,12 +656,41 @@
            }
         }
      );
      if (this.lazyInit) {
         this.initObserver(container);
      }
   }
   unregisterFields(container) {
      container.querySelectorAll('[data-type="selector"],[data-field-type="selector"]').forEach(
         selector=> {
            this.fields.delete(selector.dataset.fieldId);
         }
      );
   }
   initObserver(container){
      this.lazyObserver = new IntersectionObserver((entries) => {
         entries.forEach(entry => {
            if (entry.isIntersecting && entry.target.dataset.lazy) {
               delete entry.target.dataset.lazy;
               this.registerField(entry.target);
               this.lazyObserver.unobserve(entry.target);
            }
         });
      }, {rootMargin: '50px'});
      container.querySelectorAll('[data-type="selector"][data-lazy], [data-field-type="selector"][data-lazy]').forEach(field => {
         this.lazyObserver.observe(field);
      });
   }
   registerField(element, options = {}) {
      if (element.dataset.fieldId && this.fields.has(element.dataset.fieldId)) {
         return element.dataset.fieldId; // Already registered
      }
      let input = element.querySelector('input[type="hidden"]');
      if (!input && !Object.hasOwn(element.dataset, 'filter')) {
         console.warn('TaxonomySelector: No hidden input found for field', element);
         return;
      }
@@ -520,7 +714,6 @@
            autocomplete: Object.hasOwn(button.dataset, 'autocomplete'),
            creatable: Object.hasOwn(button.dataset, 'creatable')
         };
         if (Object.keys(options).length === 0) return;
      } else if (Object.hasOwn(options, 'toggle')) {
         button = document.querySelector(options.toggle);
         selectors.toggle = options.toggle;
@@ -571,7 +764,18 @@
      if (this.isInitializing) {
         this.batchFetch.add(config.taxonomy);
      }
      this.updateFieldUI(fieldId);
      if (element.offsetParent !== null) {
         this.updateFieldUI(fieldId);
      } else {
         // Defer until visible
         requestIdleCallback(() => {
            if (element.offsetParent !== null) {
               this.updateFieldUI(fieldId);
            }
         }, {timeout: 2000});
      }
      return fieldId;
   }
@@ -637,7 +841,8 @@
      const field = this.fields.get(fieldId);
      if (!field) return;
      let selected = Array.from(this.selectedTerms.get(fieldId));
      field.ui.value.value = selected.join(',');
      field.ui.value.value = selected.join(',')??'';
      field.ui.value.dispatchEvent(new Event('change', { bubbles: true }));
   }
   checkLimits(fieldId) {
@@ -675,6 +880,7 @@
         .some(term=>term.taxonomy === taxonomy);
      fields.forEach(field => {
         if (!field.toggle) return;
         field.toggle.disabled = !hasItems && !field.canCreate;
         field.toggle.title = !hasItems
            ? `No ${field.singular} available`
@@ -698,6 +904,7 @@
         if (this.ui.terms.sentinel) {
            this.observer.unobserve(this.ui.terms.sentinel);
         }
         return;
      }
      this.setCreateButton(true);
@@ -713,76 +920,20 @@
      const currentParent = this.store.filters.parent??0;
      this.ui.nav.back.hidden = currentParent === 0;
      const fragment = document.createDocumentFragment();
      terms.forEach(term => {
         const element = this.createTermElement({
            show: showPath,
            ... term
         });
         if (element) {
            fragment.append(element);
         }
      });
      window.chunkIt(
         terms,
         (term) => this.createTermElement({show:showPath, ... term}),
         (fragment) => this.ui.terms.list.append(fragment),
         10
      ).then(()=>{});
      if (terms.length > 0) {
         this.setMessage(false);
      }
      this.ui.terms.list.append(fragment);
   }
   createTermElement(term) {
      if (!term || !term.name) return null;
      const item = window.getTemplate('termListItem');
      item.dataset.id = term.id;
      const isSelected = this.selectedTerms.get(this.activeField).has(term.id);
      let [
         checkbox,
         label,
         nameSpan
      ] = [
         item.querySelector('input'),
         item.querySelector('label'),
         item.querySelector('span, .term-name')
      ];
      let field = this.currentField();
      let limitReached = field.limit > 0 && this.selectedTerms.get(this.activeField).size >= field.limit;
      if (checkbox && label && nameSpan) {
         [
            checkbox.dataset.id,
            checkbox.id,
            checkbox.name,
            checkbox.value,
            checkbox.disabled,
            checkbox.checked,
            label.htmlFor,
            label.title,
            label.dataset.path,
            nameSpan.textContent
         ] = [
            term.id,
            `${field.element.id}-${term.id}`,
            `${field.element.id}-${field.taxonomy}-select`,
            term.id,
            !isSelected && limitReached,
            isSelected,
            `${field.element.id}-${term.id}`,
            term.path??term.name,
            term.path,
            term.show ? term.path : term.name
         ];
         if (term.hasChildren) {
            const toggle = window.getTemplate('termChildrenToggle');
            if (toggle) {
               toggle.ariaLabel = `View ${field.plural} nested under ${term.name}`;
               item.append(toggle);
            }
         }
      }
      return item;
      return window.jvbTemplates.create('termListItem', term);
   }
   showAutocompleteTerms() {
@@ -797,12 +948,12 @@
      if (terms.length === 0) {
         this.setMessage(true, `No ${field.plural} found.`, false);
      } else {
         terms.forEach(term => {
            const item = this.createAutocompleteTerm(term);
            if (item) {
               dropdown.append(item);
            }
         });
         window.chunkIt(
            terms,
            (term) => this.createAutocompleteTerm(term),
            (fragment) => dropdown.append(fragment)
         ).then(()=>{});
         this.setMessage(false);
      }
      this.setCreateButton(true);
@@ -811,14 +962,9 @@
         field.ui.dropdown.wrapper.hidden = false;
      }
   }
   createAutocompleteTerm(term) {
      const item = window.getTemplate('autocompleteItem');
      if (!item) return;
      item.dataset.id = term.id;
      item.textContent = term.path || term.name;
      return item;
      return window.jvbTemplates.create('autocompleteItem', term);
   }
   /******************************************************************
    UI
@@ -827,16 +973,12 @@
      const term = this.store.get(termId);
      const field = this.fields.get(fieldId);
      if (!term || !field) return;
      //if the term already exists in the selected items, bail early
      if (field.ui.selected && field.ui.selected.querySelector(`[data-id="${termId}"]`)) return;
      const item = window.getTemplate('selectedTerm');
      if (!item) return;
      item.dataset.id = termId;
      item.dataset.taxonomy = field.taxonomy;
      item.querySelector('.item-name').textContent = term.path;
      item.querySelector('button').title = `Remove ${term.name}`;
      let item = this.getSelectedTermUI(term);
      if (field.ui.selected) {
         field.ui.selected.append(item);
@@ -867,13 +1009,7 @@
         // Add new breadcrumb
         const term = this.store.get(termId);
         if (!term) return;
         const crumb = window.getTemplate('termBreadcrumb');
         if (!crumb) return;
         crumb.dataset.id = termId;
         crumb.textContent = term.name;
         crumb.title = term.name;
         const crumb = window.jvbTemplates.create('termBreadcrumb', term);
         nav.append(crumb);
      }
@@ -896,6 +1032,13 @@
   /******************************************************************
    UTILITY
    ******************************************************************/
   checkRendered(collection, term) {
      if (!collection) return;
      if (!Object.hasOwn(collection, term.taxonomy)) {
         collection[term.taxonomy] = new Map();
      }
      return collection[term.taxonomy].has(term.id);
   }
   currentField() {
      return this.fields.get(this.activeField)??false;
   }
@@ -1163,6 +1306,7 @@
         this.observer?.unobserve(this.ui.terms.sentinel);
      }
      this.observer?.disconnect();
      this.lazyObserver?.disconnect();
      // Remove event listeners
      document.removeEventListener('click', this.clickHandler);