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 no lastClicked wrapper, clear everything
|
if (!this.lastClicked) {
|
this.clearSelection();
|
if (window.jvbA11y) {
|
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.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;
|