From 0113d2e9c9ff34a6ffb10707cc76d34b67a0c367 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Mon, 19 Jan 2026 16:29:41 +0000
Subject: [PATCH] =Refactored window.getTemplate into a full templating class window.jvbTemplates. Refactored CRUD.js, UploadManager.js, FormController.js, PopulateForm.js with that in mind

---
 assets/js/concise/HandleSelection.js |  307 +++++++++++++++++++++++++++++++++-----------------
 1 files changed, 202 insertions(+), 105 deletions(-)

diff --git a/assets/js/concise/HandleSelection.js b/assets/js/concise/HandleSelection.js
index 7981602..4d38f4e 100644
--- a/assets/js/concise/HandleSelection.js
+++ b/assets/js/concise/HandleSelection.js
@@ -1,25 +1,77 @@
 class HandleSelection {
-	constructor(options) {
-		this.container = options.container; // An actual element, not class name
-		this.selectors = {
-			item: options.item || '.item',
-			count: options.count || '.selection-count',
-			bulkControls: options.bulkControls || '.selection-actions',
-			checkbox: options.checkbox || '[name*="select-item"]',
-			selectAll: options.selectAll || '[data-select-all]',
-			wrapper: options.wrapper || ':has(.item-grid)'
+	constructor(container, options = {}) {
+		this.container = container;
+		const defaults = {
+			selectAll: {
+				checkbox: '[data-select-all]',
+				label: '.selected label',
+				span: '.selected label span',
+				target: 'data-selects',
+				count: '.selected-count, .selected .info',
+				bulkControls: '.bulk-actions',
+			},
+			items: '.item-grid',
+			wrapper: {
+				wrapper: ':has(.item-grid, [data-select-all])',
+				id: 'selection',
+			},
+			item: {
+				item: '.item',
+				idAttribute: 'id',
+				checkbox: '[name="select-item"]',
+			},
+			wrappers: {},
 		};
+		this.selectors = window.deepMerge(defaults, options);
 
-		this.ui = window.uiFromSelectors(this.selectors, this.container)
+		this.a11y = window.jvbA11y;
 
 		this.selectedItems = new Set();
 		this.lastSelected = null; // For shift+click range selection
 		this.lastSelectedWrapper = null; //Tracks which wrapper we're in
 		this.lastClicked = null;
 		this.subscribers = new Set();
+		this.items = new Map();
 
+		this.initElements();
 		this.initListeners();
+		//store items in memory if available
+		this.collectItems();
 	}
+	removeDataReferences() {
+		let selectors = JSON.parse(JSON.stringify(this.selectors));
+		delete selectors.item.idAttribute;
+		delete selectors.wrapper.id;
+		delete selectors.selectAll.target;
+		return selectors;
+	}
+	initElements() {
+		this.index = 0;
+		let selectors = this.removeDataReferences();
+		this.ui = window.uiFromSelectors(selectors, this.container);
+
+		this.container.querySelectorAll(this.selectors.wrapper.wrapper).forEach(wrapper => {
+			this.addWrapper(wrapper);
+		});
+	}
+		addWrapper(el) {
+			let id = this.selectors.wrapper.id;
+			if (!Object.hasOwn(el.dataset, id)) {
+				el.setAttribute(`data-${id}`, this.index);
+				this.index++;
+			}
+			let selectors = this.removeDataReferences().selectAll;
+
+			//store the DOM of the grid and selectAll
+			this.ui.wrappers[el.dataset[id]] = {
+				element: el,
+				items: el.querySelector(this.selectors.items),
+				selectAll: window.uiFromSelectors(selectors, el)
+			};
+		}
+		removeWrapper(el) {
+			delete this.ui.wrappers[el.dataset[this.selectors.wrapper.id]];
+		}
 
 	initListeners() {
 		this.clickHandler = this.handleClick.bind(this);
@@ -28,29 +80,29 @@
 
 		this.container.addEventListener('change', this.changeHandler);
 		this.container.addEventListener('click', this.clickHandler);
-		this.container.addEventListener('keydown', this.keyHandler);
+		document.addEventListener('keydown', this.keyHandler);
 	}
 
 	handleChange(e) {
-		// Select all
-		if (e.target.matches(this.selectors.selectAll)) {
+		if (!this.container.contains(e.target)) return;
+
+		// Select all checkbox
+		if (e.target.matches(this.selectors.selectAll.checkbox)) {
 			this.handleSelectAll(e.target);
 			return;
 		}
 
-		// Individual checkbox
-		if (e.target.matches(this.selectors.checkbox)) {
-			const item = e.target.closest(this.selectors.item);
+		// Individual checkbox - only process if not already handled by shift-click
+		if (e.target.matches(this.selectors.item.checkbox)) {
+			const item = e.target.closest(this.selectors.item.item);
 			if (!item) return;
 
-			// Find the immediate wrapper - check group first, then preview
-			const wrapper = this.getItemWrapper(item);
-			const id = this.getItemId(item);
+			const wrapper = this.getItemWrapper(e.target);
+			if (!wrapper) return;
 
-			// Clear selection if clicking in different wrapper without shift
-			if (this.lastSelectedWrapper && wrapper && wrapper !== this.lastSelectedWrapper && !e.shiftKey) {
-				this.clearSelection();
-			}
+			this.lastClicked = wrapper.element;
+			const id = this.getItemId(item);
+			if (!id) return;
 
 			if (e.target.checked) {
 				this.select(id, false);
@@ -59,42 +111,38 @@
 			}
 
 			this.lastSelected = id;
-			this.lastSelectedWrapper = wrapper;
+			this.lastSelectedWrapper = wrapper.element;
+			this.updateSelectionUI();
 		}
 	}
 
 	handleClick(e) {
-		const item = e.target.closest(this.selectors.item);
-		const wrapper = item ? this.getItemWrapper(item) : null;
-
-		if (wrapper) {
-			this.lastClicked = wrapper;
-		}
-
-		// Handle non-checkbox clicks on items
-		if (item && !e.target.matches(this.selectors.checkbox)) {
-			if (this.lastSelectedWrapper && wrapper && wrapper !== this.lastSelectedWrapper && !e.shiftKey) {
-				this.clearSelection();
-				this.lastSelectedWrapper = wrapper;
-			}
-		}
-
-		// Shift+click for range selection
+		// Only care about shift-clicks on checkboxes/labels
 		if (!e.shiftKey) return;
 
-		const checkbox = e.target.closest(this.selectors.checkbox);
-		if (!checkbox || !this.lastSelected || !this.lastSelectedWrapper) return;
+		const item = e.target.closest(this.selectors.item.item);
+		if (!item) return;
 
-		if (!item || !wrapper) return;
+		// Check if clicking checkbox or its label
+		const clickedCheckbox = e.target.matches(this.selectors.item.checkbox);
+		const clickedLabel = e.target.closest('label[for]');
 
-		// Range selection only works within the same wrapper
-		if (wrapper !== this.lastSelectedWrapper) return;
+		if (!clickedCheckbox && !clickedLabel) return;
 
-		const items = Array.from(wrapper.querySelectorAll(this.selectors.item));
+		const wrapper = this.getItemWrapper(item);
+		if (!wrapper) return;
+		// Can't do range selection without a previous selection in same wrapper
+		if (!this.lastSelected || !this.lastSelectedWrapper || wrapper.element !== this.lastSelectedWrapper) {
+			return; // Let change handler deal with it normally
+		}
+
+		e.preventDefault(); // Stop the checkbox from toggling
+
+		// Do range selection
 		const currentId = this.getItemId(item);
-
-		const lastIndex = items.findIndex(el => this.getItemId(el) === this.lastSelected);
-		const currentIndex = items.findIndex(el => this.getItemId(el) === currentId);
+		const items = this.getWrapperChildren(wrapper);
+		const lastIndex = items.findIndex(itemId => itemId === this.lastSelected);
+		const currentIndex = items.findIndex(itemId => itemId === currentId);
 
 		if (lastIndex === -1 || currentIndex === -1) return;
 
@@ -102,77 +150,71 @@
 		const rangeItems = items.slice(start, end + 1);
 
 		rangeItems.forEach(rangeItem => {
-			this.select(this.getItemId(rangeItem));
+			this.select(rangeItem,true,false);
 		});
 
+		this.lastSelected = currentId;
+		this.updateSelectionUI();
+
 		this.notify('range-selected', {
 			selectedItems: new Set(this.selectedItems),
 			wrapper: wrapper
 		});
 	}
 
+	getWrapperChildren(wrapper) {
+		return Array.from(wrapper.items.children)
+			.map(item => this.getItemId(item));
+	}
+
 	getItemWrapper(item) {
 		if (!item) return null;
+		let wrapper = item.closest(this.selectors.wrapper.wrapper);
+		if (!wrapper)return null;
+		return this.getWrapper(wrapper);
+	}
 
-		// Split the compound selector and check each one
-		const wrapperSelectors = this.selectors.wrapper.split(',').map(s => s.trim());
-
-		for (const selector of wrapperSelectors) {
-			const wrapper = item.closest(selector);
-			if (wrapper) return wrapper;
-		}
-
-		return null;
+	getWrapper(wrapper) {
+		return this.ui.wrappers[wrapper.dataset[this.selectors.wrapper.id]]??null;
 	}
 
 	handleKeys(e) {
-		// Ctrl/Cmd + A: Select all
-		if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
-			e.preventDefault();
-
-			if (this.lastClicked) {
-				let check = this.lastClicked.querySelector(this.selectors.selectAll);
-				if (check) {
-					check.checked = true;
-				}
-			}
-		}
-
 		// Escape: Deselect all
 		if (e.key === 'Escape' && this.selectedItems.size > 0) {
-			this.clearSelection();
-			if (window.jvbA11y) {
-				window.jvbA11y.announce('Selection cleared');
+			e.preventDefault();
+			if (Object.keys(this.ui.wrappers).length > 1 && this.lastClicked) {
+				this.clearWrapperSelection(this.lastClicked);
+			} else {
+				this.clearSelection();
 			}
 		}
 	}
 
 	handleSelectAll(trigger) {
-		const wrapper = this.getItemWrapper(trigger) || trigger.closest(this.selectors.wrapper);
+		const wrapper = this.getItemWrapper(trigger);
 		if (!wrapper) return;
 
 		// Clear any existing selection from other wrappers first
-		if (this.lastSelectedWrapper && wrapper !== this.lastSelectedWrapper) {
-			this.clearSelection();
-		}
+		//Add this back if it makes more sense to clear between select-alls
+		// if (this.lastSelectedWrapper && wrapper.element !== this.lastSelectedWrapper) {
+		// 	this.clearSelection();
+		// }
 
-		const items = wrapper.querySelectorAll(this.selectors.item);
-		const ids = Array.from(items).map(item => this.getItemId(item));
+		const ids = this.getWrapperChildren(wrapper);
 
 		if (trigger.checked) {
 			ids.forEach(id => this.select(id, true, false));
-			this.lastSelectedWrapper = wrapper;
+			this.lastSelectedWrapper = wrapper.element;
 		} else {
 			ids.forEach(id => this.deselect(id, true, false));
-			if (this.selectedItems.size === 0) {
-				this.lastSelectedWrapper = null;
-			}
+			this.lastSelectedWrapper = null;
 		}
 
-		let label = trigger.nextElementSibling || trigger.previousElementSibling;
-		if (label && label.tagName === 'LABEL') {
-			label.textContent = (trigger.checked && items.length > 0) ? 'Clear Selection' : 'Select All';
+		// Update label text
+		if (wrapper.selectAll.span) {
+			wrapper.selectAll.span.textContent = (trigger.checked && ids.length > 0) ? 'Clear Selection' : 'Select All';
 		}
+
 		this.updateSelectionUI();
 
 		this.notify('select-all', {
@@ -184,7 +226,11 @@
 	}
 
 	getItemId(item) {
-		return item.dataset.uploadId;
+		if (!item instanceof Element) {
+			item = item.element??false;
+			if (!item) return;
+		}
+		return item.dataset[`${this.selectors.item.idAttribute}`];
 	}
 
 	/*******************************************************************
@@ -194,6 +240,8 @@
 		if (this.selectedItems.has(id)) return;
 
 		this.selectedItems.add(id);
+		let item = this.getItem(id);
+		if (item) item.element.classList.add('selected');
 		if (updateCheckbox) this.setCheckboxState(id, true);
 		if (updateUI) {
 			this.updateSelectionUI();
@@ -205,6 +253,8 @@
 		if (!this.selectedItems.has(id)) return;
 
 		this.selectedItems.delete(id);
+		let item = this.getItem(id);
+		if (item) item.element.classList.remove('selected');
 		if (updateCheckbox) this.setCheckboxState(id, false);
 		if (updateUI) {
 			this.updateSelectionUI();
@@ -218,22 +268,34 @@
 	}
 
 	clearSelection() {
-		this.selectedItems.forEach(id => this.setCheckboxState(id, false));
+		this.selectedItems.forEach(id => this.deselect(id,true,false));
 		this.selectedItems.clear();
 		this.lastSelected = null;
 		this.lastSelectedWrapper = null;
 
 		// Uncheck all select-all triggers
-		this.container.querySelectorAll(this.selectors.selectAll).forEach(trigger => {
-			trigger.checked = false;
-			const label = trigger.nextElementSibling || trigger.previousElementSibling;
-			if (label?.tagName === 'LABEL') {
-				label.textContent = 'Select All';
+		for (let wrapper of Object.values(this.ui.wrappers)) {
+			if (wrapper.selectAll.checkbox) wrapper.selectAll.checkbox.checked = false;
+			if (wrapper.selectAll.span) {
+				wrapper.selectAll.span.textContent = 'Select All';
 			}
-		});
+		}
+		this.a11y.announce('Selection cleared');
 		this.updateSelectionUI();
 		this.notify('selection-cleared', { selectedItems: new Set() });
 	}
+	clearWrapperSelection(wrapper) {
+		wrapper = this.getWrapper(wrapper);
+		if(!wrapper) return;
+		this.getWrapperChildren(wrapper).forEach(id => this.deselect(id, true, false));
+		if (wrapper.selectAll.checkbox) wrapper.selectAll.checkbox.checked = false;
+		if (wrapper.selectAll.span) {
+			wrapper.selectAll.span.textContent = 'Select All';
+		}
+		this.a11y.announce('Selection cleared in group');
+		this.updateSelectionUI();
+		this.notify('wrapper-selection-cleared', {selectedItems: this.selectedItems});
+	}
 
 	isSelected(id) {
 		return this.selectedItems.has(id);
@@ -247,32 +309,67 @@
 	 * DOM HELPERS
 	 *******************************************************************/
 	setCheckboxState(id, checked) {
-		const item = this.container.querySelector(`[data-upload-id="${id}"]`);
-		const checkbox = item?.querySelector(this.selectors.checkbox);
-		if (checkbox && checkbox.checked !== checked) {
-			checkbox.checked = checked;
+		const item = this.getItem(id);
+		if (!item || !item.checkbox) return;
+
+		if (item.checkbox.checked !== checked) {
+			item.checkbox.checked = checked;
 		}
 	}
 	updateSelectionUI() {
-		if (!this.lastClicked || !this.ui.count) return;
+		if (!this.lastClicked) return;
+
+		const wrapper = this.getWrapper(this.lastClicked);
+		if (!wrapper || !wrapper.selectAll) return;
 
 		const count = this.selectedItems.size;
 
-		// Update bulk controls visibility
-		let controls = this.lastClicked.querySelector(this.selectors.bulkControls);
+		// Fix property paths:
+		let controls = wrapper.selectAll.bulkControls;
 		if (controls) {
 			controls.hidden = count === 0;
 		}
 
-		// Update count display
-		let countEl = this.lastClicked.querySelector(this.selectors.count);
+		let countEl = wrapper.selectAll.count;
 		if (countEl) {
 			const itemText = count === 1 ? 'item' : 'items';
-			countEl.textContent = count === 0 ? '' : `{ ${count} ${itemText} selected }`;
+			countEl.textContent = count === 0 ? '' : `${count} ${itemText} selected`;
 			countEl.hidden = count === 0;
 		}
 	}
 	/*******************************************************************
+	 ITEM DOM CACHING
+	*******************************************************************/
+	collectItems() {
+		this.container.querySelectorAll(this.selectors.item.item).forEach(item => {
+			this.setItem(item, true);
+		});
+	}
+
+	getItem(id) {
+		if (this.items.has(id)) return this.items.get(id);
+		return this.setItem(id);
+	}
+	setItem(id, isElement = false) {
+		let element = (!isElement)
+			? this.container.querySelector(`[data-${this.camelToKebab(this.selectors.item.idAttribute)}="${id}"]`)
+			: id;
+		if (!element) return null;
+
+		id = this.getItemId(element);
+		if (this.items.has(id)) return this.items.get(id);
+
+		this.items.set(id, {
+			element: element,
+			checkbox: element.querySelector(this.selectors.item.checkbox)
+		});
+		return this.items.get(id);
+	}
+
+	camelToKebab(str) {
+		return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
+	}
+	/*******************************************************************
 	 * EVENT SYSTEM
 	 *******************************************************************/
 	subscribe(callback) {

--
Gitblit v1.10.0