From 7a9054bb3f033c98067b3196378311dae54c5fbf Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Tue, 20 Jan 2026 01:31:53 +0000
Subject: [PATCH] =OperationQueue refactor to the JVBase/managers/queue namespace

---
 assets/js/concise/TaxonomySelector.js |  309 ++++++++++++++++++++++++++++++++------------------
 1 files changed, 197 insertions(+), 112 deletions(-)

diff --git a/assets/js/concise/TaxonomySelector.js b/assets/js/concise/TaxonomySelector.js
index 5244f02..bdbe06b 100644
--- a/assets/js/concise/TaxonomySelector.js
+++ b/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
 		);
 	}
 
@@ -455,6 +556,10 @@
 	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);
 
@@ -522,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',
@@ -549,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;
 		}
 
@@ -578,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;
@@ -629,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;
 	}
@@ -695,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) {
@@ -757,6 +904,7 @@
 			if (this.ui.terms.sentinel) {
 				this.observer.unobserve(this.ui.terms.sentinel);
 			}
+			return;
 		}
 
 		this.setCreateButton(true);
@@ -772,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() {
@@ -856,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);
@@ -870,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
@@ -886,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);
@@ -926,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);
 		}
@@ -955,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;
 	}
@@ -1222,6 +1306,7 @@
 			this.observer?.unobserve(this.ui.terms.sentinel);
 		}
 		this.observer?.disconnect();
+		this.lazyObserver?.disconnect();
 
 		// Remove event listeners
 		document.removeEventListener('click', this.clickHandler);

--
Gitblit v1.10.0