Every column table in the classifier now has the same three powers: multi-column sort, per-column autofilters, and drag-resizable widths. Two of the three tables were each missing one; the fix is to make the shared seltable the single engine. - shared/seltable.js: add multi-column sort (click a header to sort; shift/ctrl- click ADDS a secondary key; asc→desc→off cycle; ▲/▼ + priority indicator) and self-contained drag-resizable columns (a .seltable__resizer on each title th — no dependency on the classifier-only resize.js, since the tables tool shares seltable). New opt-in opts.persistKey persists widths + sort to localStorage. - By-tracking grid → seltable (target-tree.js): the bespoke merged-cell table is replaced by a seltable (status badge, original-name preview link, editable tracking/rev/title inputs, ✕). It inherits sort + autofilter + resize for free. The Columns ▾ chooser now rebuilds the seltable; widths + sort persist under the same prefs key. Grid is self-correcting — a column-set change rebuilds on render. - Worklist (already seltable): gains sort + resize automatically; the derived Source column is marked non-sortable/non-filterable. - The Rename-in-place spreadsheet already had all three (sort.js + filter.js + resize.js) — no change needed there. - seltable.css: .seltable__resizer / .seltable__sortind / sortable-header cursor. - tests: seltable multi-sort (header click, shift-add, indicators) + resize (drag widens + persists via persistKey); the grid's selectors move off the old .ttable--grid onto .seltable__table. 66 classify + 56 classifier/tables green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
268 lines
14 KiB
JavaScript
268 lines
14 KiB
JavaScript
/**
|
|
* 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 };
|
|
})();
|