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;