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)' }; this.ui = window.uiFromSelectors(this.selectors, this.container) 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.initListeners(); } initListeners() { this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); this.keyHandler = this.handleKeys.bind(this); 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.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'); } } } 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; } } let label = trigger.nextElementSibling || trigger.previousElementSibling; if (label && label.tagName === 'LABEL') { label.textContent = (trigger.checked && items.length > 0) ? 'Clear Selection' : 'Select All'; } this.updateSelectionUI(); this.notify('select-all', { wrapper: wrapper, checked: trigger.checked, ids: ids, selectedItems: new Set(this.selectedItems) }); } getItemId(item) { return item.dataset.uploadId; } /******************************************************************* * 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) }); } 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) }); } toggle(id) { this.selectedItems.has(id) ? this.deselect(id) : this.select(id); this.updateSelectionUI(); } 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() }); } isSelected(id) { return this.selectedItems.has(id); } getSelection() { return new Set(this.selectedItems); } /******************************************************************* * 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; } } 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 => { try { cb(event, data); } catch (e) { console.error('HandleSelection subscriber error:', e); } }); } destroy() { this.container.removeEventListener('change', this.changeHandler); this.container.removeEventListener('click', this.clickHandler); this.container.removeEventListener('keydown', this.keyHandler); this.subscribers.clear(); this.selectedItems.clear(); } } window.jvbHandleSelection = HandleSelection;