Jake Vanderwerf
9 days ago 47e77f9fac1155c536b2b87fec552c7fcce66fa6
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) {