Jake Vanderwerf
2026-01-02 aeb5a13bfa203281aaa5573e19fe5aa6ac012152
assets/js/concise/HandleSelection.js
@@ -1,398 +1,302 @@
/**
 * 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(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)'
      };
      this.ui = window.uiFromSelectors(this.selectors, this.container)
      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.init();
      this.initListeners();
   }
   init() {
      // Bind event handlers
   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('click', this.clickHandler);
      this.container.addEventListener('keydown', this.keyHandler);
   }
   handleChange(e) {
      // Select all
      if (e.target.matches(this.selectors.selectAll)) {
         this.handleSelectAll(e.target);
         return;
      }
      // Individual checkbox
      if (e.target.matches(this.selectors.checkbox)) {
         const item = e.target.closest(this.selectors.item);
         if (!item) return;
         // Find the immediate wrapper - check group first, then preview
         const wrapper = this.getItemWrapper(item);
         const id = this.getItemId(item);
         // Clear selection if clicking in different wrapper without shift
         if (this.lastSelectedWrapper && wrapper && wrapper !== this.lastSelectedWrapper && !e.shiftKey) {
            this.clearSelection();
         }
         if (e.target.checked) {
            this.select(id, false);
         } else {
            this.deselect(id, false);
         }
         this.lastSelected = id;
         this.lastSelectedWrapper = wrapper;
      }
   }
   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
      if (!e.shiftKey) return;
      const checkbox = e.target.closest(this.selectors.checkbox);
      if (!checkbox || !this.lastSelected || !this.lastSelectedWrapper) return;
      if (!item || !wrapper) return;
      // Range selection only works within the same wrapper
      if (wrapper !== this.lastSelectedWrapper) return;
      const items = Array.from(wrapper.querySelectorAll(this.selectors.item));
      const currentId = this.getItemId(item);
      const lastIndex = items.findIndex(el => this.getItemId(el) === this.lastSelected);
      const currentIndex = items.findIndex(el => this.getItemId(el) === currentId);
      if (lastIndex === -1 || currentIndex === -1) return;
      const [start, end] = [Math.min(lastIndex, currentIndex), Math.max(lastIndex, currentIndex)];
      const rangeItems = items.slice(start, end + 1);
      rangeItems.forEach(rangeItem => {
         this.select(this.getItemId(rangeItem));
      });
      this.notify('range-selected', {
         selectedItems: new Set(this.selectedItems),
         wrapper: wrapper
      });
   }
   getItemWrapper(item) {
      if (!item) return null;
      // 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;
   }
   handleKeys(e) {
      // Ctrl/Cmd + A: Select all
      if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
         e.preventDefault();
         if (this.ui.selectAll) {
            this.ui.selectAll.checked = true;
            this.selectAll(true);
            if (window.jvbA11y) {
               window.jvbA11y.announce('All items selected');
         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.selectAll(false);
         this.clearSelection();
         if (window.jvbA11y) {
            window.jvbA11y.announce('Selection cleared');
         }
      }
   }
      // 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);
   handleSelectAll(trigger) {
      const wrapper = this.getItemWrapper(trigger) || trigger.closest(this.selectors.wrapper);
      if (!wrapper) return;
      // Clear any existing selection from other wrappers first
      if (this.lastSelectedWrapper && wrapper !== this.lastSelectedWrapper) {
         this.clearSelection();
      }
      const items = wrapper.querySelectorAll(this.selectors.item);
      const ids = Array.from(items).map(item => this.getItemId(item));
      if (trigger.checked) {
         ids.forEach(id => this.select(id, true, false));
         this.lastSelectedWrapper = wrapper;
      } else {
         ids.forEach(id => this.deselect(id, true, false));
         if (this.selectedItems.size === 0) {
            this.lastSelectedWrapper = null;
         }
      }
   }
   handleClick(e) {
      const checkbox = e.target.closest(`${this.checkboxSelector}, label[for]`);
      if (!checkbox) return;
      // Get the actual checkbox element
      const input = checkbox.tagName === 'LABEL'
         ? document.getElementById(checkbox.getAttribute('for'))
         : checkbox;
      if (!input) return;
      // 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;
         }
      let label = trigger.nextElementSibling || trigger.previousElementSibling;
      if (label && label.tagName === 'LABEL') {
         label.textContent = (trigger.checked && items.length > 0) ? 'Clear Selection' : 'Select All';
      }
   }
   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);
      }
      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
         });
      }
      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);
      if (lastIndex === -1 || currentIndex === -1) return;
      // Determine range (handle both directions)
      const startIndex = Math.min(lastIndex, currentIndex);
      const endIndex = Math.max(lastIndex, currentIndex);
      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);
         if (checkbox && id) {
            checkbox.checked = isChecked;
            this.selectedItems.add(id);
         }
      }
      // Update last selected to current
      this.lastSelected = currentItem;
      this.updateSelectionUI();
      this.notify('range-selected', {
         selectedItems: this.selectedItems,
         container: this.container
         wrapper: wrapper,
         checked: trigger.checked,
         ids: ids,
         selectedItems: new Set(this.selectedItems)
      });
      // 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;
   getItemId(item) {
      return item.dataset.uploadId;
   }
      // Update bulk controls visibility
      if (this.ui.bulkControls) {
         this.ui.bulkControls.hidden = count === 0;
   /*******************************************************************
    * PUBLIC API
    *******************************************************************/
   select(id, updateCheckbox = true, updateUI = true) {
      if (this.selectedItems.has(id)) return;
      this.selectedItems.add(id);
      if (updateCheckbox) this.setCheckboxState(id, true);
      if (updateUI) {
         this.updateSelectionUI();
      }
      this.notify('item-selected', { id, selectedItems: new Set(this.selectedItems) });
   }
      // 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;
   deselect(id, updateCheckbox = true, updateUI = true) {
      if (!this.selectedItems.has(id)) return;
      this.selectedItems.delete(id);
      if (updateCheckbox) this.setCheckboxState(id, false);
      if (updateUI) {
         this.updateSelectionUI();
      }
      this.notify('item-deselected', { id, selectedItems: new Set(this.selectedItems) });
   }
      // 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;
   toggle(id) {
      this.selectedItems.has(id) ? this.deselect(id) : this.select(id);
      this.updateSelectionUI();
   }
         // 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';
   clearSelection() {
      this.selectedItems.forEach(id => this.setCheckboxState(id, 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';
         }
      }
      });
      this.updateSelectionUI();
      this.notify('selection-cleared', { selectedItems: new Set() });
   }
   /**
    * Get item ID from element
    */
   getItemId(element) {
      const item = element.closest(this.itemSelector);
      if (!item) return null;
      // Try common ID attributes in order
      return item.dataset.id ||
         item.dataset.itemId ||
         item.dataset.uploadId ||
         item.id;
   }
   /**
    * Get all selected item IDs as array
    */
   getSelected() {
      return Array.from(this.selectedItems);
   }
   /**
    * Check if an item is selected
    */
   isSelected(id) {
      return this.selectedItems.has(id);
   }
   /**
    * Programmatically select specific items
    */
   select(ids) {
      const idArray = Array.isArray(ids) ? ids : [ids];
      idArray.forEach(id => {
         this.selectedItems.add(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 = true;
         }
      });
      this.updateSelectionUI();
      this.notify('item-selected', {
         selectedItem: id,
         selectedItems: this.selectedItems,
         container: this.container
      });
   getSelection() {
      return new Set(this.selectedItems);
   }
   /**
    * 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
      });
   /*******************************************************************
    * 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;
      }
   }
   /**
    * Event system
    */
   updateSelectionUI() {
      if (!this.lastClicked || !this.ui.count) return;
      const count = this.selectedItems.size;
      // Update bulk controls visibility
      let controls = this.lastClicked.querySelector(this.selectors.bulkControls);
      if (controls) {
         controls.hidden = count === 0;
      }
      // Update count display
      let countEl = this.lastClicked.querySelector(this.selectors.count);
      if (countEl) {
         const itemText = count === 1 ? 'item' : 'items';
         countEl.textContent = count === 0 ? '' : `{ ${count} ${itemText} selected }`;
         countEl.hidden = count === 0;
      }
   }
   /*******************************************************************
    * 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;