feat(centralui): column resize and reorder for the audit results grid

Adds drag-to-resize and drag-to-reorder column UX to AuditResultsGrid,
with chosen widths + column order persisted in browser sessionStorage.

- wwwroot/js/audit-grid.js: dependency-free helper — pointer-driven
  resize handles, native HTML5 drag-and-drop reorder, and a
  sessionStorage save/load wrapper (mirrors treeview-storage.js).
- AuditResultsGrid: renders a resize handle per <th>, makes headers
  draggable, applies persisted widths via a --audit-col-width custom
  property, and wires reorder into the existing ColumnOrder /
  OrderedColumns() mechanism. JS-invokable OnColumnResized /
  OnColumnReordered persist + re-render. A stored order naming an
  unknown column degrades gracefully (drops unknown keys, appends
  missing columns in default order); widths clamp to a 64px minimum.
- AuditResultsGrid.razor.css: subtle scoped styling for the resize
  handle affordance and the reorder drop-target highlight.
- App.razor references audit-grid.js alongside the other scripts.
- Tests: 6 new bUnit tests for the load/apply/persist logic and
  graceful degradation; a new AuditGridColumnTests Playwright suite
  for the drag UX + reload persistence. Audit page bUnit tests set
  loose JSInterop mode since the grid now calls into audit-grid.js.
This commit is contained in:
Joseph Doherty
2026-05-21 06:27:46 -04:00
parent f64a7aed02
commit f1478c5a19
9 changed files with 900 additions and 15 deletions

View File

@@ -0,0 +1,184 @@
// Audit results grid column UX (#23 follow-ups Task 10).
//
// A tiny, dependency-free helper for the AuditResultsGrid component:
// - drag-to-resize: a pointer-driven handle on each <th>'s right edge,
// - drag-to-reorder: native HTML5 drag-and-drop on the header row,
// - save/load: a sessionStorage round-trip, mirroring treeview-storage.js.
//
// The Blazor component owns the column model; this file is purely the
// browser-side drag plumbing. After a resize or reorder it calls back into
// .NET via a DotNetObjectReference so the component can persist + re-render.
//
// No drag-drop libraries — hand-rolled pointer + native-DnD handlers only.
window.auditGrid = {
// --- sessionStorage wrapper (mirrors window.treeviewStorage) -----------
// Keys are namespaced under "auditGrid:" so they never collide with the
// treeview's "treeview:" namespace.
save: function (key, json) {
try {
sessionStorage.setItem("auditGrid:" + key, json);
} catch {
// Quota / privacy-mode failures are non-fatal — the grid simply
// falls back to defaults on the next load.
}
},
load: function (key) {
try {
return sessionStorage.getItem("auditGrid:" + key);
} catch {
return null;
}
},
// Minimum column width in pixels. A column can never be dragged narrower
// than this so a header can't collapse to an unclickable sliver.
minWidth: 64,
// --- wire-up ----------------------------------------------------------
// `table` is the <table> element, `dotNet` is a DotNetObjectReference
// exposing OnColumnResized / OnColumnReordered. Safe to call on every
// render: it re-scans the header and binds only cells not already bound,
// and always refreshes the live .NET reference. Handlers read the column
// key live from data-col-key at event time, so Blazor reusing a <th> DOM
// node for a different column (after a reorder re-render) is harmless.
init: function (table, dotNet) {
if (!table) {
return;
}
table.__auditGridDotNet = dotNet;
var headerRow = table.tHead && table.tHead.rows[0];
if (!headerRow) {
return;
}
for (var i = 0; i < headerRow.cells.length; i++) {
this._bindHeaderCell(table, headerRow.cells[i]);
}
},
// Bind resize + reorder handlers to a single <th>. Idempotent — a cell
// already carrying handlers is skipped. The handlers resolve the column
// key live (th.getAttribute) so they stay correct if the renderer reuses
// the element for another column.
_bindHeaderCell: function (table, th) {
var self = this;
if (th.__auditGridCellBound) {
return;
}
th.__auditGridCellBound = true;
// --- resize: pointer drag on the handle ---------------------------
var handle = th.querySelector(".audit-grid-resize-handle");
if (handle) {
handle.addEventListener("pointerdown", function (ev) {
ev.preventDefault();
// Stop the pointerdown from also starting a header drag.
ev.stopPropagation();
var startX = ev.clientX;
var startWidth = th.getBoundingClientRect().width;
handle.setPointerCapture(ev.pointerId);
th.classList.add("resizing");
function onMove(moveEv) {
var next = Math.max(self.minWidth, startWidth + (moveEv.clientX - startX));
self._applyWidth(th, next);
}
function onUp() {
handle.releasePointerCapture(ev.pointerId);
handle.removeEventListener("pointermove", onMove);
handle.removeEventListener("pointerup", onUp);
handle.removeEventListener("pointercancel", onUp);
th.classList.remove("resizing");
var key = th.getAttribute("data-col-key");
var finalWidth = Math.round(th.getBoundingClientRect().width);
var dn = table.__auditGridDotNet;
if (key && dn) {
dn.invokeMethodAsync("OnColumnResized", key, finalWidth);
}
}
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
handle.addEventListener("pointercancel", onUp);
});
}
// --- reorder: native HTML5 drag-and-drop on the header ------------
// The whole <th> is draggable; dropping it onto another header swaps
// the dragged column into the drop target's position.
th.setAttribute("draggable", "true");
th.addEventListener("dragstart", function (ev) {
// A resize in progress sets .resizing; never start a reorder then.
if (th.classList.contains("resizing")) {
ev.preventDefault();
return;
}
var key = th.getAttribute("data-col-key");
if (!key) {
ev.preventDefault();
return;
}
table.__auditGridDragKey = key;
ev.dataTransfer.effectAllowed = "move";
// Some browsers require data to be set for the drag to begin.
try { ev.dataTransfer.setData("text/plain", key); } catch { /* ignore */ }
th.classList.add("dragging");
});
th.addEventListener("dragend", function () {
th.classList.remove("dragging");
table.__auditGridDragKey = null;
self._clearDropTargets(table);
});
th.addEventListener("dragover", function (ev) {
// Allowing the drop is what lets dragover/drop fire at all.
var key = th.getAttribute("data-col-key");
if (key && table.__auditGridDragKey && table.__auditGridDragKey !== key) {
ev.preventDefault();
ev.dataTransfer.dropEffect = "move";
th.classList.add("drop-target");
}
});
th.addEventListener("dragleave", function () {
th.classList.remove("drop-target");
});
th.addEventListener("drop", function (ev) {
ev.preventDefault();
th.classList.remove("drop-target");
var key = th.getAttribute("data-col-key");
var fromKey = table.__auditGridDragKey;
table.__auditGridDragKey = null;
if (!key || !fromKey || fromKey === key) {
return;
}
var dn = table.__auditGridDotNet;
if (dn) {
// fromKey moves to occupy toKey's slot; the component computes
// the resulting order and re-renders + persists.
dn.invokeMethodAsync("OnColumnReordered", fromKey, key);
}
});
},
// Apply a width to a <th> via a CSS custom property. The scoped stylesheet
// reads --audit-col-width; absent it, the column falls back to auto.
_applyWidth: function (th, widthPx) {
th.style.setProperty("--audit-col-width", widthPx + "px");
},
_clearDropTargets: function (table) {
var hits = table.querySelectorAll(".drop-target, .dragging");
for (var i = 0; i < hits.length; i++) {
hits[i].classList.remove("drop-target", "dragging");
}
}
};