/**
|
* 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.container.addEventListener('click', this.clickHandler);
|
this.container.addEventListener('change', this.changeHandler);
|
this.container.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;
|