From 97e7c319d656a5f05489ca996e249e7359303d4d Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sun, 31 May 2026 22:42:33 +0000
Subject: [PATCH] =Jakevan edits done?
---
assets/js/concise/HandleSelection.js | 639 +++++++++++++++++++++++++++++-----------------------------
1 files changed, 320 insertions(+), 319 deletions(-)
diff --git a/assets/js/concise/HandleSelection.js b/assets/js/concise/HandleSelection.js
index 52e233d..4d38f4e 100644
--- a/assets/js/concise/HandleSelection.js
+++ b/assets/js/concise/HandleSelection.js
@@ -1,398 +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)
+ });
}
- /**
- * Get all selected item IDs as array
- */
- getSelected() {
- return Array.from(this.selectedItems);
+ getItemId(item) {
+ if (!item instanceof Element) {
+ item = item.element??false;
+ if (!item) return;
+ }
+ return item.dataset[`${this.selectors.item.idAttribute}`];
}
- /**
- * Check if an item is selected
- */
+ /*******************************************************************
+ * 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