class HandleSelection {
|
constructor(container, options = {}) {
|
this.container = container;
|
const defaults = {
|
selectAll: {
|
checkbox: '[data-select-all]',
|
label: '.selected label',
|
span: '.selected label span',
|
target: 'data-selects',
|
count: '.selected-count, .selected .info',
|
bulkControls: '.bulk-actions',
|
},
|
items: '.item-grid',
|
wrapper: {
|
wrapper: ':has(.item-grid, [data-select-all])',
|
id: 'selection',
|
},
|
item: {
|
item: '.item',
|
idAttribute: 'id',
|
checkbox: '[name="select-item"]',
|
},
|
wrappers: {},
|
};
|
this.selectors = window.deepMerge(defaults, options);
|
|
this.a11y = window.jvbA11y;
|
|
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.items = new Map();
|
|
this.initElements();
|
this.initListeners();
|
//store items in memory if available
|
this.collectItems();
|
}
|
removeDataReferences() {
|
let selectors = JSON.parse(JSON.stringify(this.selectors));
|
delete selectors.item.idAttribute;
|
delete selectors.wrapper.id;
|
delete selectors.selectAll.target;
|
return selectors;
|
}
|
initElements() {
|
this.index = 0;
|
let selectors = this.removeDataReferences();
|
this.ui = window.uiFromSelectors(selectors, this.container);
|
|
this.container.querySelectorAll(this.selectors.wrapper.wrapper).forEach(wrapper => {
|
this.addWrapper(wrapper);
|
});
|
}
|
addWrapper(el) {
|
let id = this.selectors.wrapper.id;
|
if (!Object.hasOwn(el.dataset, id)) {
|
el.setAttribute(`data-${id}`, this.index);
|
this.index++;
|
}
|
let selectors = this.removeDataReferences().selectAll;
|
|
//store the DOM of the grid and selectAll
|
this.ui.wrappers[el.dataset[id]] = {
|
element: el,
|
items: el.querySelector(this.selectors.items),
|
selectAll: window.uiFromSelectors(selectors, el)
|
};
|
}
|
removeWrapper(el) {
|
delete this.ui.wrappers[el.dataset[this.selectors.wrapper.id]];
|
}
|
|
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);
|
document.addEventListener('keydown', this.keyHandler);
|
}
|
|
handleChange(e) {
|
if (!this.container.contains(e.target)) return;
|
|
// Select all checkbox
|
if (e.target.matches(this.selectors.selectAll.checkbox)) {
|
this.handleSelectAll(e.target);
|
return;
|
}
|
|
// Individual checkbox - only process if not already handled by shift-click
|
if (e.target.matches(this.selectors.item.checkbox)) {
|
const item = e.target.closest(this.selectors.item.item);
|
if (!item) return;
|
|
const wrapper = this.getItemWrapper(e.target);
|
if (!wrapper) return;
|
|
this.lastClicked = wrapper.element;
|
const id = this.getItemId(item);
|
if (!id) return;
|
|
if (e.target.checked) {
|
this.select(id, false);
|
} else {
|
this.deselect(id, false);
|
}
|
|
this.lastSelected = id;
|
this.lastSelectedWrapper = wrapper.element;
|
this.updateSelectionUI();
|
}
|
}
|
|
handleClick(e) {
|
// Only care about shift-clicks on checkboxes/labels
|
if (!e.shiftKey) return;
|
|
const item = e.target.closest(this.selectors.item.item);
|
if (!item) return;
|
|
// Check if clicking checkbox or its label
|
const clickedCheckbox = e.target.matches(this.selectors.item.checkbox);
|
const clickedLabel = e.target.closest('label[for]');
|
|
if (!clickedCheckbox && !clickedLabel) return;
|
|
const wrapper = this.getItemWrapper(item);
|
if (!wrapper) return;
|
// Can't do range selection without a previous selection in same wrapper
|
if (!this.lastSelected || !this.lastSelectedWrapper || wrapper.element !== this.lastSelectedWrapper) {
|
return; // Let change handler deal with it normally
|
}
|
|
e.preventDefault(); // Stop the checkbox from toggling
|
|
// Do range selection
|
const currentId = this.getItemId(item);
|
const items = this.getWrapperChildren(wrapper);
|
const lastIndex = items.findIndex(itemId => itemId === this.lastSelected);
|
const currentIndex = items.findIndex(itemId => itemId === 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(rangeItem,true,false);
|
});
|
|
this.lastSelected = currentId;
|
this.updateSelectionUI();
|
|
this.notify('range-selected', {
|
selectedItems: new Set(this.selectedItems),
|
wrapper: wrapper
|
});
|
}
|
|
getWrapperChildren(wrapper) {
|
return Array.from(wrapper.items.children)
|
.map(item => this.getItemId(item));
|
}
|
|
getItemWrapper(item) {
|
if (!item) return null;
|
let wrapper = item.closest(this.selectors.wrapper.wrapper);
|
if (!wrapper)return null;
|
return this.getWrapper(wrapper);
|
}
|
|
getWrapper(wrapper) {
|
return this.ui.wrappers[wrapper.dataset[this.selectors.wrapper.id]]??null;
|
}
|
|
handleKeys(e) {
|
// Escape: Deselect all
|
if (e.key === 'Escape' && this.selectedItems.size > 0) {
|
e.preventDefault();
|
if (Object.keys(this.ui.wrappers).length > 1 && this.lastClicked) {
|
this.clearWrapperSelection(this.lastClicked);
|
} else {
|
this.clearSelection();
|
}
|
}
|
}
|
|
handleSelectAll(trigger) {
|
const wrapper = this.getItemWrapper(trigger);
|
if (!wrapper) return;
|
|
// Clear any existing selection from other wrappers first
|
//Add this back if it makes more sense to clear between select-alls
|
// if (this.lastSelectedWrapper && wrapper.element !== this.lastSelectedWrapper) {
|
// this.clearSelection();
|
// }
|
|
const ids = this.getWrapperChildren(wrapper);
|
|
if (trigger.checked) {
|
ids.forEach(id => this.select(id, true, false));
|
this.lastSelectedWrapper = wrapper.element;
|
} else {
|
ids.forEach(id => this.deselect(id, true, false));
|
this.lastSelectedWrapper = null;
|
}
|
|
// Update label text
|
if (wrapper.selectAll.span) {
|
wrapper.selectAll.span.textContent = (trigger.checked && ids.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) {
|
if (!item instanceof Element) {
|
item = item.element??false;
|
if (!item) return;
|
}
|
return item.dataset[`${this.selectors.item.idAttribute}`];
|
}
|
|
/*******************************************************************
|
* PUBLIC API
|
*******************************************************************/
|
select(id, updateCheckbox = true, updateUI = true) {
|
if (this.selectedItems.has(id)) return;
|
|
this.selectedItems.add(id);
|
let item = this.getItem(id);
|
if (item) item.element.classList.add('selected');
|
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);
|
let item = this.getItem(id);
|
if (item) item.element.classList.remove('selected');
|
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.deselect(id,true,false));
|
this.selectedItems.clear();
|
this.lastSelected = null;
|
this.lastSelectedWrapper = null;
|
|
// Uncheck all select-all triggers
|
for (let wrapper of Object.values(this.ui.wrappers)) {
|
if (wrapper.selectAll.checkbox) wrapper.selectAll.checkbox.checked = false;
|
if (wrapper.selectAll.span) {
|
wrapper.selectAll.span.textContent = 'Select All';
|
}
|
}
|
this.a11y.announce('Selection cleared');
|
this.updateSelectionUI();
|
this.notify('selection-cleared', { selectedItems: new Set() });
|
}
|
clearWrapperSelection(wrapper) {
|
wrapper = this.getWrapper(wrapper);
|
if(!wrapper) return;
|
this.getWrapperChildren(wrapper).forEach(id => this.deselect(id, true, false));
|
if (wrapper.selectAll.checkbox) wrapper.selectAll.checkbox.checked = false;
|
if (wrapper.selectAll.span) {
|
wrapper.selectAll.span.textContent = 'Select All';
|
}
|
this.a11y.announce('Selection cleared in group');
|
this.updateSelectionUI();
|
this.notify('wrapper-selection-cleared', {selectedItems: this.selectedItems});
|
}
|
|
isSelected(id) {
|
return this.selectedItems.has(id);
|
}
|
|
getSelection() {
|
return new Set(this.selectedItems);
|
}
|
|
/*******************************************************************
|
* DOM HELPERS
|
*******************************************************************/
|
setCheckboxState(id, checked) {
|
const item = this.getItem(id);
|
if (!item || !item.checkbox) return;
|
|
if (item.checkbox.checked !== checked) {
|
item.checkbox.checked = checked;
|
}
|
}
|
updateSelectionUI() {
|
if (!this.lastClicked) return;
|
|
const wrapper = this.getWrapper(this.lastClicked);
|
if (!wrapper || !wrapper.selectAll) return;
|
|
const count = this.selectedItems.size;
|
|
// Fix property paths:
|
let controls = wrapper.selectAll.bulkControls;
|
if (controls) {
|
controls.hidden = count === 0;
|
}
|
|
let countEl = wrapper.selectAll.count;
|
if (countEl) {
|
const itemText = count === 1 ? 'item' : 'items';
|
countEl.textContent = count === 0 ? '' : `${count} ${itemText} selected`;
|
countEl.hidden = count === 0;
|
}
|
}
|
/*******************************************************************
|
ITEM DOM CACHING
|
*******************************************************************/
|
collectItems() {
|
this.container.querySelectorAll(this.selectors.item.item).forEach(item => {
|
this.setItem(item, true);
|
});
|
}
|
|
getItem(id) {
|
if (this.items.has(id)) return this.items.get(id);
|
return this.setItem(id);
|
}
|
setItem(id, isElement = false) {
|
let element = (!isElement)
|
? this.container.querySelector(`[data-${this.camelToKebab(this.selectors.item.idAttribute)}="${id}"]`)
|
: id;
|
if (!element) return null;
|
|
id = this.getItemId(element);
|
if (this.items.has(id)) return this.items.get(id);
|
|
this.items.set(id, {
|
element: element,
|
checkbox: element.querySelector(this.selectors.item.checkbox)
|
});
|
return this.items.get(id);
|
}
|
|
camelToKebab(str) {
|
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
|
}
|
/*******************************************************************
|
* 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;
|