ZDDC/shared/seltable.js
ZDDC 93f1eb8d63 refactor(seltable): promote to shared/ with per-column autofilters
Move classifier/js/seltable.js → shared/seltable.js so both the classifier and
the tables tool can use it (the MDL realignment splits work across both). Adds
per-column autofilter inputs (one per column, AND-combined) on top of the
programmatic global filter; selection + select-filtered + ctrl-Enter unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 14:40:56 -05:00

178 lines
9.2 KiB
JavaScript

/**
* ZDDC — shared selectable + autofilter table (used by the classifier catalog
* and the tables tool's "Add from archive").
*
* A flat table with PER-COLUMN autofilters (one input per column, AND-combined,
* each an AND of space-separated terms) plus an optional programmatic global
* filter, and powerful selection for building complex sets quickly:
* 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 order. Selection is keyed by a stable
* rowId so it survives filtering and re-render.
*/
(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 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)
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); }
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');
columns.forEach(function (c) { htr.appendChild(elt('th', c.cls || null, c.title || c.key)); });
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', '');
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(); }
});
renderBody();
}
function renderBody() {
if (!bodyEl) return;
var fr = filtered();
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,
clickRow: function (id, mods) {
var fr = filtered();
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 };
})();