/** * 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"]'; this.selectedItems = new Set(); this.lastSelected = null; this.subscribers = new Set(); this.init(); } init() { // Bind event handlers this.clickHandler = this.handleClick.bind(this); this.changeHandler = this.handleChange.bind(this); this.keyHandler = this.handleKeys.bind(this); // Attach listeners this.ui.wrapper.addEventListener('click', this.clickHandler); this.ui.wrapper.addEventListener('change', this.changeHandler); this.ui.wrapper.addEventListener('keydown', this.keyHandler); } 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'); } } } // Escape: Deselect all if (e.key === 'Escape' && this.selectedItems.size > 0) { this.selectAll(false); 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); } } } 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; } } } 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 }); // 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; // Update bulk controls visibility if (this.ui.bulkControls) { this.ui.bulkControls.hidden = count === 0; } // 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; } // 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'; } } } /** * 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; } /** * 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 }); } /** * 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 }); } /** * Event system */ subscribe(callback) { this.subscribers.add(callback); return () => this.subscribers.delete(callback); } notify(event, data) { this.subscribers.forEach(cb => cb(event, data)); } /** * 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.subscribers.clear(); // Clear references this.container = null; this.ui = null; this.lastSelected = null; } } // Export for use in other modules window.jvbHandleSelection = HandleSelection;