From 3baf3d2545ba6ece6b74a64c0def59bd0774cf54 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Wed, 10 Jun 2026 16:34:12 +0000
Subject: [PATCH] =Laid the groundwork for an improved DashboardManager.php setup. Have to put it aside so I can get the dang Northeh done though.

---
 assets/js/concise/HandleSelection.js |  636 +++++++++++++++++++++++++++++----------------------------
 1 files changed, 322 insertions(+), 314 deletions(-)

diff --git a/assets/js/concise/HandleSelection.js b/assets/js/concise/HandleSelection.js
index 16ad1bf..4d38f4e 100644
--- a/assets/js/concise/HandleSelection.js
+++ b/assets/js/concise/HandleSelection.js
@@ -1,391 +1,399 @@
-/**
- * HandleSelection - Reusable selection management for items in grids
- *
- * Handles selection logic including:
- * - Individual item selection/deselection
- * - Select all / Clear all
- * - Range selection (shift+click)
- * - Selection count updates
- * - Bulk action visibility
- *
- * @class HandleSelection
- */
 class HandleSelection {
-	/**
-	 * @param {Object} config - Configuration object
-	 * @param {HTMLElement} config.container - Container element holding items
-	 * @param {Object} config.ui - UI element references
-	 * @param {HTMLElement} config.ui.selectAll - Select all checkbox
-	 * @param {HTMLElement} config.ui.bulkControls - Bulk actions container
-	 * @param {HTMLElement} config.ui.count - Selection count display
-	 * @param {string} config.itemSelector - Selector for individual items
-	 * @param {string} config.checkboxSelector - Selector for item checkboxes
-	 * @param {Function} config.onSelectionChange - Optional callback when selection changes
-	 */
-	constructor(config) {
-		this.container = config.container;
-		this.ui = config.ui || {};
-		this.itemSelector = config.itemSelector || '.item';
-		this.checkboxSelector = config.checkboxSelector || '[name*="select-item"]';
+	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.a11y = window.jvbA11y;
 
 		this.selectedItems = new Set();
-		this.lastSelected = null;
+		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.init();
+		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);
 
-	init() {
-		// Bind event handlers
+		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);
 		this.changeHandler = this.handleChange.bind(this);
 		this.keyHandler = this.handleKeys.bind(this);
 
-		// Attach listeners
-		this.container.addEventListener('click', this.clickHandler);
 		this.container.addEventListener('change', this.changeHandler);
-		this.container.addEventListener('keydown', this.keyHandler);
+		this.container.addEventListener('click', this.clickHandler);
+		document.addEventListener('keydown', this.keyHandler);
 	}
 
-	handleKeys(e) {
-		// Ctrl/Cmd + A: Select all
-		if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
-			e.preventDefault();
+	handleChange(e) {
+		if (!this.container.contains(e.target)) return;
 
-			if (this.ui.selectAll) {
-				this.ui.selectAll.checked = true;
-				this.selectAll(true);
-				if (window.jvbA11y) {
-					window.jvbA11y.announce('All items selected');
-				}
-			}
+		// Select all checkbox
+		if (e.target.matches(this.selectors.selectAll.checkbox)) {
+			this.handleSelectAll(e.target);
+			return;
 		}
 
-		// Escape: Deselect all
-		if (e.key === 'Escape' && this.selectedItems.size > 0) {
-			this.selectAll(false);
-			if (window.jvbA11y) {
-				window.jvbA11y.announce('Selection cleared');
-			}
-		}
+		// 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;
 
-		// Delete/Backspace: Remove selected items
-		if ((e.key === 'Delete' || e.key === 'Backspace') &&
-			!e.target.matches('input, textarea')
-			&& this.selectedItems > 0) {
-			e.preventDefault();
-			if (confirm(`Remove ${this.selectedItems.size} selected item${this.selectedItems.size !== 1 ? 's' : ''}?`)) {
-				this.deselect(this.selectedItems);
+			const wrapper = this.getItemWrapper(e.target);
+			if (!wrapper) return;
+
+			this.lastClicked = wrapper.element;
+			const id = this.getItemId(item);
+			if (!id) return;
+
+			if (e.target.checked) {
+				this.select(id, false);
+			} else {
+				this.deselect(id, false);
 			}
+
+			this.lastSelected = id;
+			this.lastSelectedWrapper = wrapper.element;
+			this.updateSelectionUI();
 		}
 	}
 
 	handleClick(e) {
-		const checkbox = e.target.closest(`${this.checkboxSelector}, label[for]`);
-		if (!checkbox) return;
+		// Only care about shift-clicks on checkboxes/labels
+		if (!e.shiftKey) return;
 
-		// Get the actual checkbox element
-		const input = checkbox.tagName === 'LABEL'
-			? document.getElementById(checkbox.getAttribute('for'))
-			: checkbox;
+		const item = e.target.closest(this.selectors.item.item);
+		if (!item) return;
 
-		if (!input) return;
+		// Check if clicking checkbox or its label
+		const clickedCheckbox = e.target.matches(this.selectors.item.checkbox);
+		const clickedLabel = e.target.closest('label[for]');
 
-		// Handle shift+click for range selection
-		if (e.shiftKey && this.lastSelected) {
-			e.preventDefault();
-			this.handleRangeSelection(input);
-		} else {
-			// Store last clicked for range selection
-			const item = input.closest(this.itemSelector);
-			if (item) {
-				this.lastSelected = item;
-			}
-		}
-	}
+		if (!clickedCheckbox && !clickedLabel) return;
 
-	handleChange(e) {
-		if (this.ui.selectAll && e.target === this.ui.selectAll) {
-			this.selectAll(e.target.checked);
-		} else {
-			const checkbox = e.target.closest(this.checkboxSelector);
-			if (!checkbox) return;
-
-			this.toggleSelection(this.getItemId(checkbox));
-		}
-	}
-
-	/**
-	 * Toggle selection state of an item
-	 */
-	toggleSelection(id) {
-		if (!id) return;
-
-		let selected = true;
-		if (this.selectedItems.has(id)) {
-			selected = false;
-			this.selectedItems.delete(id);
-		} else {
-			this.selectedItems.add(id);
+		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
 		}
 
-		if (selected) {
-			this.notify('item-selected', {
-				selectedItem: id,
-				selectedItems: this.selectedItems,
-				container: this.container
-			});
-		} else {
-			this.notify('item-deselected', {
-				selectedItem: id,
-				selectedItems: this.selectedItems,
-				container: this.container
-			});
-		}
+		e.preventDefault(); // Stop the checkbox from toggling
 
-
-		this.updateSelectionUI();
-	}
-
-	/**
-	 * Select or deselect all items
-	 */
-	selectAll(checked) {
-		const items = this.container.querySelectorAll(this.itemSelector);
-
-		if (!checked) {
-			this.selectedItems.clear();
-			if (this.ui.selectAll) {
-				this.ui.selectAll.checked = false;
-			}
-		}
-
-		items.forEach(item => {
-			const id = this.getItemId(item);
-			const checkbox = item.querySelector(this.checkboxSelector);
-
-			if (checkbox) {
-				checkbox.checked = checked;
-			}
-
-			if (checked && id) {
-				this.selectedItems.add(id);
-			}
-		});
-
-		this.notify('select-all', {
-			container: this.container,
-			selected: checked,
-			items: items
-		})
-
-		this.updateSelectionUI();
-	}
-
-	/**
-	 * Clear all selections
-	 */
-	clearSelection() {
-		this.selectAll(false);
-	}
-
-	/**
-	 * Handle shift+click range selection
-	 */
-	handleRangeSelection(currentCheckbox) {
-		if (!this.lastSelected) {
-			this.lastSelected = currentCheckbox.closest(this.itemSelector);
-			return;
-		}
-
-		const currentItem = currentCheckbox.closest(this.itemSelector);
-		if (!currentItem) return;
-
-		// Get all items
-		const allItems = Array.from(this.container.querySelectorAll(this.itemSelector));
-
-		// Find indices
-		const lastIndex = allItems.indexOf(this.lastSelected);
-		const currentIndex = allItems.indexOf(currentItem);
+		// Do range selection
+		const currentId = this.getItemId(item);
+		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;
 
-		// Determine range (handle both directions)
-		const startIndex = Math.min(lastIndex, currentIndex);
-		const endIndex = Math.max(lastIndex, currentIndex);
+		const [start, end] = [Math.min(lastIndex, currentIndex), Math.max(lastIndex, currentIndex)];
+		const rangeItems = items.slice(start, end + 1);
 
-		let isChecked = !currentCheckbox.checked;
-		// Select all items in range
-		for (let i = startIndex; i <= endIndex; i++) {
-			const item = allItems[i];
-			const checkbox = item.querySelector(this.checkboxSelector);
-			const id = this.getItemId(item);
+		rangeItems.forEach(rangeItem => {
+			this.select(rangeItem,true,false);
+		});
 
-			if (checkbox && id) {
-				checkbox.checked = isChecked;
-				this.selectedItems.add(id);
-			}
-		}
-
-		// Update last selected to current
-		this.lastSelected = currentItem;
-
+		this.lastSelected = currentId;
 		this.updateSelectionUI();
 
 		this.notify('range-selected', {
-			selectedItems: this.selectedItems,
-			container: this.container
+			selectedItems: new Set(this.selectedItems),
+			wrapper: wrapper
 		});
-
-		// Announce for accessibility
-		const selectedCount = endIndex - startIndex + 1;
-		if (window.jvbA11y) {
-			window.jvbA11y.announce(`Selected ${selectedCount} items in range`);
-		}
 	}
 
-	/**
-	 * Update selection UI elements
-	 */
-	updateSelectionUI() {
-		const count = this.selectedItems.size;
-		const totalItems = this.container.querySelectorAll(this.itemSelector).length;
+	getWrapperChildren(wrapper) {
+		return Array.from(wrapper.items.children)
+			.map(item => this.getItemId(item));
+	}
 
-		// Update bulk controls visibility
-		if (this.ui.bulkControls) {
-			this.ui.bulkControls.hidden = count === 0;
-		}
+	getItemWrapper(item) {
+		if (!item) return null;
+		let wrapper = item.closest(this.selectors.wrapper.wrapper);
+		if (!wrapper)return null;
+		return this.getWrapper(wrapper);
+	}
 
-		// Update count display
-		if (this.ui.count) {
-			const itemText = count === 1 ? 'item' : 'items';
-			this.ui.count.textContent = count === 0 ? '' : `{ ${count} ${itemText} selected }`;
-			this.ui.count.hidden = count === 0;
-		}
+	getWrapper(wrapper) {
+		return this.ui.wrappers[wrapper.dataset[this.selectors.wrapper.id]]??null;
+	}
 
-		// Update select all checkbox state
-		if (this.ui.selectAll) {
-			this.ui.selectAll.checked = totalItems > 0 && count === totalItems;
-			this.ui.selectAll.indeterminate = count > 0 && count < totalItems;
-
-			// Update label text if available
-			const label = this.ui.selectAll.nextElementSibling ||
-				this.ui.selectAll.previousElementSibling;
-			if (label && label.tagName === 'LABEL') {
-				label.textContent = (totalItems > 0 && count === totalItems)
-					? 'Clear Selection'
-					: 'Select All';
+	handleKeys(e) {
+		// Escape: Deselect all
+		if (e.key === 'Escape' && this.selectedItems.size > 0) {
+			e.preventDefault();
+			if (Object.keys(this.ui.wrappers).length > 1 && this.lastClicked) {
+				this.clearWrapperSelection(this.lastClicked);
+			} else {
+				this.clearSelection();
 			}
 		}
 	}
 
-	/**
-	 * Get item ID from element
-	 */
-	getItemId(element) {
-		const item = element.closest(this.itemSelector);
-		if (!item) return null;
+	handleSelectAll(trigger) {
+		const wrapper = this.getItemWrapper(trigger);
+		if (!wrapper) return;
 
-		// Try common ID attributes in order
-		return item.dataset.id ||
-			item.dataset.itemId ||
-			item.dataset.uploadId ||
-			item.id;
+		// Clear any existing selection from other wrappers first
+		//Add this back if it makes more sense to clear between select-alls
+		// if (this.lastSelectedWrapper && wrapper.element !== this.lastSelectedWrapper) {
+		// 	this.clearSelection();
+		// }
+
+		const ids = this.getWrapperChildren(wrapper);
+
+		if (trigger.checked) {
+			ids.forEach(id => this.select(id, true, false));
+			this.lastSelectedWrapper = wrapper.element;
+		} else {
+			ids.forEach(id => this.deselect(id, true, false));
+			this.lastSelectedWrapper = null;
+		}
+
+		// 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', {
+			wrapper: wrapper,
+			checked: trigger.checked,
+			ids: ids,
+			selectedItems: new Set(this.selectedItems)
+		});
 	}
 
-	/**
-	 * Check if an item is selected
-	 */
+	getItemId(item) {
+		if (!item instanceof Element) {
+			item = item.element??false;
+			if (!item) return;
+		}
+		return item.dataset[`${this.selectors.item.idAttribute}`];
+	}
+
+	/*******************************************************************
+	 * PUBLIC API
+	 *******************************************************************/
+	select(id, updateCheckbox = true, updateUI = true) {
+		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();
+		}
+		this.notify('item-selected', { id, selectedItems: new Set(this.selectedItems) });
+	}
+
+	deselect(id, updateCheckbox = true, updateUI = true) {
+		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();
+		}
+		this.notify('item-deselected', { id, selectedItems: new Set(this.selectedItems) });
+	}
+
+	toggle(id) {
+		this.selectedItems.has(id) ? this.deselect(id) : this.select(id);
+		this.updateSelectionUI();
+	}
+
+	clearSelection() {
+		this.selectedItems.forEach(id => this.deselect(id,true,false));
+		this.selectedItems.clear();
+		this.lastSelected = null;
+		this.lastSelectedWrapper = null;
+
+		// Uncheck all select-all triggers
+		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);
 	}
 
-	/**
-	 * Programmatically select specific items
-	 */
-	select(ids) {
-		const idArray = Array.isArray(ids) ? ids : [ids];
+	getSelection() {
+		return new Set(this.selectedItems);
+	}
 
-		idArray.forEach(id => {
-			this.selectedItems.add(id);
+	/*******************************************************************
+	 * DOM HELPERS
+	 *******************************************************************/
+	setCheckboxState(id, checked) {
+		const item = this.getItem(id);
+		if (!item || !item.checkbox) return;
 
-			// Update checkbox if element exists
-			const item = this.container.querySelector(`${this.itemSelector}[data-id="${id}"]`);
-			if (item) {
-				const checkbox = item.querySelector(this.checkboxSelector);
-				if (checkbox) checkbox.checked = true;
-			}
-		});
+		if (item.checkbox.checked !== checked) {
+			item.checkbox.checked = checked;
+		}
+	}
+	updateSelectionUI() {
+		if (!this.lastClicked) return;
 
-		this.updateSelectionUI();
+		const wrapper = this.getWrapper(this.lastClicked);
+		if (!wrapper || !wrapper.selectAll) return;
 
-		this.notify('item-selected', {
-			selectedItem: id,
-			selectedItems: this.selectedItems,
-			container: this.container
+		const count = this.selectedItems.size;
+
+		// Fix property paths:
+		let controls = wrapper.selectAll.bulkControls;
+		if (controls) {
+			controls.hidden = count === 0;
+		}
+
+		let countEl = wrapper.selectAll.count;
+		if (countEl) {
+			const itemText = count === 1 ? 'item' : 'items';
+			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);
 		});
 	}
 
-	/**
-	 * Programmatically deselect specific items
-	 */
-	deselect(ids) {
-		const idArray = Array.isArray(ids) ? ids : [ids];
-
-		idArray.forEach(id => {
-			this.selectedItems.delete(id);
-
-			// Update checkbox if element exists
-			const item = this.container.querySelector(`${this.itemSelector}[data-id="${id}"]`);
-			if (item) {
-				const checkbox = item.querySelector(this.checkboxSelector);
-				if (checkbox) checkbox.checked = false;
-			}
-		});
-
-		this.updateSelectionUI();
-
-		this.notify('item-deselected', {
-			selectedItem: id,
-			selectedItems: this.selectedItems,
-			container: this.container
-		});
+	getItem(id) {
+		if (this.items.has(id)) return this.items.get(id);
+		return this.setItem(id);
 	}
-	/**
-	 * Event system
-	 */
+	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) {
 		this.subscribers.add(callback);
 		return () => this.subscribers.delete(callback);
 	}
 
 	notify(event, data) {
-		this.subscribers.forEach(cb => cb(event, data));
+		this.subscribers.forEach(cb => {
+			try {
+				cb(event, data);
+			} catch (e) {
+				console.error('HandleSelection subscriber error:', e);
+			}
+		});
 	}
 
-	/**
-	 * Clean up event listeners
-	 */
 	destroy() {
-		// Remove event listeners
-		if (this.container) {
-			this.container.removeEventListener('click', this.clickHandler);
-			this.container.removeEventListener('change', this.changeHandler);
-			this.container.removeEventListener('keydown', this.keyHandler);
-		}
-
-		// Clear selections
-		this.clearSelection();
-
-		// Clear subscribers
+		this.container.removeEventListener('change', this.changeHandler);
+		this.container.removeEventListener('click', this.clickHandler);
+		this.container.removeEventListener('keydown', this.keyHandler);
 		this.subscribers.clear();
-
-		// Clear references
-		this.container = null;
-		this.ui = null;
-		this.lastSelected = null;
+		this.selectedItems.clear();
 	}
 }
 
-// Export for use in other modules
 window.jvbHandleSelection = HandleSelection;

--
Gitblit v1.10.0