/** * ZDDC — shared selectable + autofilter table (used by the classifier worklist + * By-tracking grid, and the tables tool's "Add from archive"). * * A flat table with PER-COLUMN autofilters (one input per column, AND-combined), * MULTI-COLUMN SORT (click a header to sort; shift/ctrl-click adds a secondary * key), RESIZABLE columns (drag the header edge), an optional programmatic global * filter, and powerful selection: * click replace selection + set anchor * ctrl/cmd-click toggle one row * shift-click range from the anchor (replaces the selection) * ctrl-shift-click ADD the anchor→row range to the existing selection * ctrl/cmd-Enter fire onActivate(selectedIds) — a bulk action * Esc clear * Ranges run over the CURRENTLY FILTERED + SORTED order. Selection is keyed by a * stable rowId so it survives filtering, sorting, and re-render. Pass * opts.persistKey to remember column widths + sort across reloads (localStorage). * * Column config: { key, title, cls?, get?(row), render?(row, td), filterable?, * sortable?, sortValue?(row) }. sortable/filterable default true; set false for * render-only columns (a remove button, a derived badge). */ (function () { 'use strict'; if (!window.app) window.app = {}; if (!window.app.modules) window.app.modules = {}; function terms(q) { return String(q == null ? '' : q).trim().toLowerCase().split(/\s+/).filter(Boolean); } function hit(text, ts) { var t = String(text == null ? '' : text).toLowerCase(); for (var i = 0; i < ts.length; i++) { if (t.indexOf(ts[i]) === -1) return false; } return true; } function elt(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; } function loadPersist(key) { try { return JSON.parse(localStorage.getItem(key)) || {}; } catch (_) { return {}; } } function savePersist(key, patch) { var cur = loadPersist(key); for (var k in patch) cur[k] = patch[k]; try { localStorage.setItem(key, JSON.stringify(cur)); } catch (_) { /* private mode */ } } function create(opts) { var container = opts.container; var columns = opts.columns || []; var rowId = opts.rowId || function (r) { return r.id; }; var getRows = (typeof opts.rows === 'function') ? opts.rows : function () { return opts.rows || []; }; var selected = Object.create(null); // id -> true var anchorId = null; var globalTerms = []; // programmatic global filter (tests/reveal) var colFilters = Object.create(null); // colKey -> terms[] (the per-column autofilters) var persistKey = opts.persistKey || null; var saved = persistKey ? loadPersist(persistKey) : {}; var sortState = Array.isArray(saved.sort) ? saved.sort.slice() : []; // [{ key, dir }] var colWidths = saved.widths || {}; // colKey -> px var headEls = Object.create(null); // colKey -> { th, ind } function rows() { return getRows() || []; } function colByKey(k) { for (var i = 0; i < columns.length; i++) { if (columns[i].key === k) return columns[i]; } return null; } function colVal(col, row) { return col.get ? col.get(row) : (row[col.key] == null ? '' : row[col.key]); } function rowBlob(row) { var s = ''; for (var i = 0; i < columns.length; i++) { s += colVal(columns[i], row) + ' '; } return s; } function rowMatches(row) { if (globalTerms.length && !hit(rowBlob(row), globalTerms)) return false; for (var k in colFilters) { var col = colByKey(k); if (col && !hit(colVal(col, row), colFilters[k])) return false; } return true; } function filtered() { return rows().filter(rowMatches); } // ── multi-column sort ────────────────────────────────────────────── function sortVal(col, row) { return col.sortValue ? col.sortValue(row) : colVal(col, row); } function sorted(list) { if (!sortState.length) return list; var arr = list.slice(); arr.sort(function (a, b) { for (var i = 0; i < sortState.length; i++) { var s = sortState[i], col = colByKey(s.key); if (!col) continue; var cmp = String(sortVal(col, a)).localeCompare(String(sortVal(col, b)), undefined, { numeric: true, sensitivity: 'base' }); if (cmp) return s.dir === 'desc' ? -cmp : cmp; } return 0; }); return arr; } function visibleRows() { return sorted(filtered()); } function sortIdx(key) { for (var i = 0; i < sortState.length; i++) { if (sortState[i].key === key) return i; } return -1; } function toggleSort(key, add) { var i = sortIdx(key); if (add) { if (i >= 0) { if (sortState[i].dir === 'asc') sortState[i].dir = 'desc'; else sortState.splice(i, 1); } else sortState.push({ key: key, dir: 'asc' }); } else { if (i === 0 && sortState.length === 1) sortState = sortState[0].dir === 'asc' ? [{ key: key, dir: 'desc' }] : []; else sortState = [{ key: key, dir: 'asc' }]; } if (persistKey) savePersist(persistKey, { sort: sortState }); updateSortIndicators(); renderBody(); } function updateSortIndicators() { columns.forEach(function (c) { var h = headEls[c.key]; if (!h) return; var i = sortIdx(c.key); h.ind.textContent = i >= 0 ? ((sortState[i].dir === 'asc' ? ' ▲' : ' ▼') + (sortState.length > 1 ? (i + 1) : '')) : ''; }); } // ── resizable columns ────────────────────────────────────────────── function addResizer(th, key) { var rz = elt('div', 'seltable__resizer'); rz.addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); var startX = e.clientX, startW = th.offsetWidth; function mm(ev) { var w = Math.max(40, startW + (ev.clientX - startX)); th.style.width = th.style.minWidth = th.style.maxWidth = w + 'px'; } function mu() { document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu); colWidths[key] = Math.round(th.offsetWidth); if (persistKey) savePersist(persistKey, { widths: colWidths }); } document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu); }); th.appendChild(rz); } function getSelection() { return Object.keys(selected); } function getFilteredRows() { return filtered(); } function fireSel() { if (opts.onSelectionChange) opts.onSelectionChange(getSelection()); } function setFilter(q) { globalTerms = terms(q); renderBody(); } function setColFilter(colKey, q) { var t = terms(q); if (t.length) colFilters[colKey] = t; else delete colFilters[colKey]; renderBody(); } function selectAllFiltered() { filtered().forEach(function (r) { selected[rowId(r)] = true; }); anchorId = null; renderBody(); fireSel(); } function clearSel() { selected = Object.create(null); anchorId = null; renderBody(); fireSel(); } function onRowClick(e, row, fr) { var ids = fr.map(rowId), id = rowId(row), idx = ids.indexOf(id), aIdx; if (e.shiftKey && anchorId != null && (aIdx = ids.indexOf(anchorId)) >= 0) { if (!(e.ctrlKey || e.metaKey)) selected = Object.create(null); // shift replaces; ctrl-shift adds var lo = Math.min(aIdx, idx), hi = Math.max(aIdx, idx); for (var i = lo; i <= hi; i++) selected[ids[i]] = true; } else if (e.ctrlKey || e.metaKey) { if (selected[id]) delete selected[id]; else selected[id] = true; anchorId = id; } else { selected = Object.create(null); selected[id] = true; anchorId = id; } renderBody(); fireSel(); } var bodyEl = null, countEl = null; function render() { container.textContent = ''; container.classList.add('seltable'); var bar = elt('div', 'seltable__bar'); var allBtn = elt('button', 'btn btn-sm btn-secondary', 'Select filtered'); allBtn.addEventListener('click', selectAllFiltered); var clrBtn = elt('button', 'btn btn-sm btn-secondary', 'Clear'); clrBtn.addEventListener('click', clearSel); countEl = elt('span', 'seltable__count'); bar.appendChild(allBtn); bar.appendChild(clrBtn); bar.appendChild(countEl); container.appendChild(bar); var scroll = elt('div', 'seltable__scroll'); var table = elt('table', 'seltable__table'); var thead = elt('thead'), htr = elt('tr'); headEls = Object.create(null); columns.forEach(function (c) { var th = elt('th', c.cls || null); th.appendChild(document.createTextNode(c.title || c.key)); var ind = elt('span', 'seltable__sortind'); th.appendChild(ind); headEls[c.key] = { th: th, ind: ind }; if (colWidths[c.key]) { th.style.width = th.style.minWidth = th.style.maxWidth = colWidths[c.key] + 'px'; } if (c.sortable !== false) { th.classList.add('seltable__th--sortable'); th.addEventListener('click', function (e) { if (e.target.classList.contains('seltable__resizer')) return; toggleSort(c.key, e.shiftKey || e.ctrlKey || e.metaKey); }); } addResizer(th, c.key); htr.appendChild(th); }); if (opts.rowExtra) htr.appendChild(elt('th', 'seltable__extrah', opts.extraTitle || '')); thead.appendChild(htr); // Per-column autofilter row. var ftr = elt('tr', 'seltable__filters'); columns.forEach(function (c) { var th = elt('th'); if (c.filterable !== false) { var inp = elt('input', 'seltable__colfilter'); inp.type = 'search'; inp.placeholder = 'filter…'; inp.spellcheck = false; inp.setAttribute('data-no-select', ''); if (colFilters[c.key]) inp.value = colFilters[c.key].join(' '); inp.addEventListener('input', function () { setColFilter(c.key, this.value); }); th.appendChild(inp); } ftr.appendChild(th); }); if (opts.rowExtra) ftr.appendChild(elt('th')); thead.appendChild(ftr); table.appendChild(thead); bodyEl = elt('tbody'); table.appendChild(bodyEl); scroll.appendChild(table); container.appendChild(scroll); container.tabIndex = 0; container.addEventListener('keydown', function (e) { if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); if (opts.onActivate) opts.onActivate(getSelection()); } else if (e.key === 'Escape') { clearSel(); } }); updateSortIndicators(); renderBody(); } function renderBody() { if (!bodyEl) return; var fr = visibleRows(); bodyEl.textContent = ''; fr.forEach(function (row) { var id = rowId(row); var tr = elt('tr', 'seltable__row' + (selected[id] ? ' is-selected' : '')); tr.dataset.id = id; tr.addEventListener('click', function (e) { if (e.target.closest('input,button,select,a,[data-no-select]')) return; onRowClick(e, row, fr); }); if (opts.onRowDrop) { tr.addEventListener('dragover', function (e) { if (window.app.modules.dnd && window.app.modules.dnd.active()) { e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; tr.classList.add('drop-hover'); } }); tr.addEventListener('dragleave', function () { tr.classList.remove('drop-hover'); }); tr.addEventListener('drop', function (e) { tr.classList.remove('drop-hover'); e.preventDefault(); var keys = window.app.modules.dnd ? window.app.modules.dnd.getDrag() : []; if (window.app.modules.dnd) window.app.modules.dnd.clearDrag(); if (keys.length) opts.onRowDrop(id, keys); }); } columns.forEach(function (c) { var td = elt('td', c.cls || null); if (c.render) c.render(row, td); else td.textContent = colVal(c, row); tr.appendChild(td); }); if (opts.rowExtra) { var ex = elt('td', 'seltable__extra'); opts.rowExtra(row, ex); tr.appendChild(ex); } bodyEl.appendChild(tr); }); if (countEl) { var nSel = getSelection().length; countEl.textContent = fr.length + ' shown' + (nSel ? ' · ' + nSel + ' selected' : ''); } } return { render: render, renderBody: renderBody, getSelection: getSelection, getFilteredRows: getFilteredRows, setFilter: setFilter, setColFilter: setColFilter, selectAllFiltered: selectAllFiltered, clear: clearSel, sortBy: toggleSort, getSortState: function () { return sortState.slice(); }, clickRow: function (id, mods) { var fr = visibleRows(); var row = fr.filter(function (r) { return String(rowId(r)) === String(id); })[0]; if (row) onRowClick(Object.assign({ shiftKey: false, ctrlKey: false, metaKey: false }, mods || {}), row, fr); }, }; } window.app.modules.seltable = { create: create }; })();