| | |
| | | /** |
| | | * 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 no lastClicked wrapper, clear everything |
| | | if (!this.lastClicked) { |
| | | this.clearSelection(); |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('All items selected'); |
| | | window.jvbA11y.announce('Selection cleared'); |
| | | } |
| | | return; |
| | | } |
| | | |
| | | // First escape: clear items in the current wrapper |
| | | const wrapperItems = this.lastClicked.querySelectorAll(this.selectors.item); |
| | | const wrapperIds = Array.from(wrapperItems).map(item => this.getItemId(item)); |
| | | const hadWrapperSelection = wrapperIds.some(id => this.selectedItems.has(id)); |
| | | |
| | | if (hadWrapperSelection) { |
| | | // Clear just the wrapper's items |
| | | wrapperIds.forEach(id => this.deselect(id)); |
| | | |
| | | // If there are still items selected elsewhere, announce partial clear |
| | | if (this.selectedItems.size > 0) { |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Selection cleared in current group'); |
| | | } |
| | | } else { |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Selection cleared'); |
| | | } |
| | | } |
| | | } else { |
| | | // Second escape or no selection in wrapper: clear everything |
| | | this.clearSelection(); |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('All selections cleared'); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // 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; |
| | | } |
| | | |
| | | /** |
| | | * 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; |