| | |
| | | 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)' |
| | | 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.ui = window.uiFromSelectors(this.selectors, this.container) |
| | | 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.container.addEventListener('change', this.changeHandler); |
| | | this.container.addEventListener('click', this.clickHandler); |
| | | this.container.addEventListener('keydown', this.keyHandler); |
| | | document.addEventListener('keydown', this.keyHandler); |
| | | } |
| | | |
| | | handleChange(e) { |
| | | // Select all |
| | | if (e.target.matches(this.selectors.selectAll)) { |
| | | 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 |
| | | if (e.target.matches(this.selectors.checkbox)) { |
| | | const item = e.target.closest(this.selectors.item); |
| | | // 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; |
| | | |
| | | // Find the immediate wrapper - check group first, then preview |
| | | const wrapper = this.getItemWrapper(item); |
| | | const id = this.getItemId(item); |
| | | const wrapper = this.getItemWrapper(e.target); |
| | | if (!wrapper) return; |
| | | |
| | | // Clear selection if clicking in different wrapper without shift |
| | | if (this.lastSelectedWrapper && wrapper && wrapper !== this.lastSelectedWrapper && !e.shiftKey) { |
| | | this.clearSelection(); |
| | | } |
| | | this.lastClicked = wrapper.element; |
| | | const id = this.getItemId(item); |
| | | if (!id) return; |
| | | |
| | | if (e.target.checked) { |
| | | this.select(id, false); |
| | |
| | | } |
| | | |
| | | this.lastSelected = id; |
| | | this.lastSelectedWrapper = wrapper; |
| | | this.lastSelectedWrapper = wrapper.element; |
| | | this.updateSelectionUI(); |
| | | } |
| | | } |
| | | |
| | | 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 |
| | | // Only care about shift-clicks on checkboxes/labels |
| | | if (!e.shiftKey) return; |
| | | |
| | | const checkbox = e.target.closest(this.selectors.checkbox); |
| | | if (!checkbox || !this.lastSelected || !this.lastSelectedWrapper) return; |
| | | const item = e.target.closest(this.selectors.item.item); |
| | | if (!item) return; |
| | | |
| | | if (!item || !wrapper) return; |
| | | // Check if clicking checkbox or its label |
| | | const clickedCheckbox = e.target.matches(this.selectors.item.checkbox); |
| | | const clickedLabel = e.target.closest('label[for]'); |
| | | |
| | | // Range selection only works within the same wrapper |
| | | if (wrapper !== this.lastSelectedWrapper) return; |
| | | if (!clickedCheckbox && !clickedLabel) return; |
| | | |
| | | const items = Array.from(wrapper.querySelectorAll(this.selectors.item)); |
| | | 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 lastIndex = items.findIndex(el => this.getItemId(el) === this.lastSelected); |
| | | const currentIndex = items.findIndex(el => this.getItemId(el) === currentId); |
| | | 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 rangeItems = items.slice(start, end + 1); |
| | | |
| | | rangeItems.forEach(rangeItem => { |
| | | this.select(this.getItemId(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); |
| | | } |
| | | |
| | | // 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; |
| | | getWrapper(wrapper) { |
| | | return this.ui.wrappers[wrapper.dataset[this.selectors.wrapper.id]]??null; |
| | | } |
| | | |
| | | handleKeys(e) { |
| | | // Ctrl/Cmd + A: Select all |
| | | if ((e.ctrlKey || e.metaKey) && e.key === 'a') { |
| | | e.preventDefault(); |
| | | |
| | | if (this.lastClicked) { |
| | | let check = this.lastClicked.querySelector(this.selectors.selectAll); |
| | | if (check) { |
| | | check.checked = true; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Escape: Deselect all |
| | | if (e.key === 'Escape' && this.selectedItems.size > 0) { |
| | | this.clearSelection(); |
| | | if (window.jvbA11y) { |
| | | window.jvbA11y.announce('Selection cleared'); |
| | | 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) || trigger.closest(this.selectors.wrapper); |
| | | const wrapper = this.getItemWrapper(trigger); |
| | | if (!wrapper) return; |
| | | |
| | | // Clear any existing selection from other wrappers first |
| | | if (this.lastSelectedWrapper && wrapper !== this.lastSelectedWrapper) { |
| | | this.clearSelection(); |
| | | } |
| | | //Add this back if it makes more sense to clear between select-alls |
| | | // if (this.lastSelectedWrapper && wrapper.element !== this.lastSelectedWrapper) { |
| | | // this.clearSelection(); |
| | | // } |
| | | |
| | | const items = wrapper.querySelectorAll(this.selectors.item); |
| | | const ids = Array.from(items).map(item => this.getItemId(item)); |
| | | const ids = this.getWrapperChildren(wrapper); |
| | | |
| | | if (trigger.checked) { |
| | | ids.forEach(id => this.select(id, true, false)); |
| | | this.lastSelectedWrapper = wrapper; |
| | | this.lastSelectedWrapper = wrapper.element; |
| | | } else { |
| | | ids.forEach(id => this.deselect(id, true, false)); |
| | | if (this.selectedItems.size === 0) { |
| | | this.lastSelectedWrapper = null; |
| | | } |
| | | 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'; |
| | | // 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', { |
| | |
| | | } |
| | | |
| | | getItemId(item) { |
| | | return item.dataset.uploadId; |
| | | if (!item instanceof Element) { |
| | | item = item.element??false; |
| | | if (!item) return; |
| | | } |
| | | return item.dataset[`${this.selectors.item.idAttribute}`]; |
| | | } |
| | | |
| | | /******************************************************************* |
| | |
| | | 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(); |
| | |
| | | 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(); |
| | |
| | | } |
| | | |
| | | clearSelection() { |
| | | this.selectedItems.forEach(id => this.setCheckboxState(id, false)); |
| | | this.selectedItems.forEach(id => this.deselect(id,true,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'; |
| | | 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); |
| | |
| | | * 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; |
| | | const item = this.getItem(id); |
| | | if (!item || !item.checkbox) return; |
| | | |
| | | if (item.checkbox.checked !== checked) { |
| | | item.checkbox.checked = checked; |
| | | } |
| | | } |
| | | updateSelectionUI() { |
| | | if (!this.lastClicked || !this.ui.count) return; |
| | | if (!this.lastClicked) return; |
| | | |
| | | const wrapper = this.getWrapper(this.lastClicked); |
| | | if (!wrapper || !wrapper.selectAll) return; |
| | | |
| | | const count = this.selectedItems.size; |
| | | |
| | | // Update bulk controls visibility |
| | | let controls = this.lastClicked.querySelector(this.selectors.bulkControls); |
| | | // Fix property paths: |
| | | let controls = wrapper.selectAll.bulkControls; |
| | | if (controls) { |
| | | controls.hidden = count === 0; |
| | | } |
| | | |
| | | // Update count display |
| | | let countEl = this.lastClicked.querySelector(this.selectors.count); |
| | | let countEl = wrapper.selectAll.count; |
| | | if (countEl) { |
| | | const itemText = count === 1 ? 'item' : 'items'; |
| | | countEl.textContent = count === 0 ? '' : `{ ${count} ${itemText} selected }`; |
| | | 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) { |