Jake Vanderwerf
106 mins ago 3baf3d2545ba6ece6b74a64c0def59bd0774cf54
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;