fix(centralui): stabilize audit grid th nodes with @key; doc grid limitations

This commit is contained in:
Joseph Doherty
2026-05-21 06:33:20 -04:00
parent f1478c5a19
commit c503df4c4c
3 changed files with 31 additions and 0 deletions

View File

@@ -17,7 +17,13 @@
<tr> <tr>
@foreach (var col in OrderedColumns()) @foreach (var col in OrderedColumns())
{ {
// @key keeps Blazor reusing one DOM node per column across
// re-renders (reorder/resize), so audit-grid.js binds drag
// listeners exactly once per <th> and never leaks them onto
// discarded nodes — the __auditGridCellBound guard relies on
// this node stability to be fully sound.
<th class="audit-grid-th" <th class="audit-grid-th"
@key="col.Key"
data-test="col-header-@col.Key" data-test="col-header-@col.Key"
data-col-key="@col.Key" data-col-key="@col.Key"
style="@ColumnWidthStyle(col.Key)"> style="@ColumnWidthStyle(col.Key)">

View File

@@ -37,6 +37,16 @@ namespace ScadaLink.CentralUI.Components.Audit;
/// <see cref="PageSize"/> rows) — that's the conventional "we've reached the /// <see cref="PageSize"/> rows) — that's the conventional "we've reached the
/// end" signal for keyset paging without a count query. /// end" signal for keyset paging without a count query.
/// </para> /// </para>
///
/// <para>
/// <b>Accessibility.</b> Column resize and reorder are mouse/pointer-only —
/// they use a pointer-driven resize handle and native HTML5 drag-and-drop with
/// no keyboard equivalent and no ARIA for the reorder. This is a conscious
/// scope decision for an internal tool, not an oversight: only the column-
/// <i>customisation</i> gesture is mouse-only. The persisted layout itself
/// renders as plain HTML, so keyboard and assistive-technology users still get
/// a fully readable, navigable grid.
/// </para>
/// </summary> /// </summary>
public partial class AuditResultsGrid : IAsyncDisposable public partial class AuditResultsGrid : IAsyncDisposable
{ {
@@ -99,6 +109,9 @@ public partial class AuditResultsGrid : IAsyncDisposable
/// <c>data-test</c> + the column-order parameter); the label is the user-facing /// <c>data-test</c> + the column-order parameter); the label is the user-facing
/// header text. Mirrors Component-AuditLog.md §10. /// header text. Mirrors Component-AuditLog.md §10.
/// </summary> /// </summary>
// Label intentionally equals Key for every column today; the separate Label
// field is future-proofing for humanised headers (e.g. "Occurred (UTC)") —
// populating it is a deliberate later change, out of scope here.
private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[] private static readonly IReadOnlyList<(string Key, string Label)> AllColumns = new[]
{ {
("OccurredAtUtc", "OccurredAtUtc"), ("OccurredAtUtc", "OccurredAtUtc"),
@@ -251,6 +264,12 @@ public partial class AuditResultsGrid : IAsyncDisposable
// is idempotent — already-bound cells are skipped, and the .NET // is idempotent — already-bound cells are skipped, and the .NET
// reference is refreshed — so a re-render after a reorder still leaves // reference is refreshed — so a re-render after a reorder still leaves
// every header cell wired without leaking handlers. // every header cell wired without leaking handlers.
//
// OnColumnResized/OnColumnReordered both call StateHasChanged(), which
// re-runs this method and calls init again. That repeat call is an
// intentional cheap no-op: the @key-stable <th> nodes plus the
// __auditGridCellBound guard mean init re-scans the header and rebinds
// nothing — so there is deliberately no gating logic here.
if (_selfRef is not null) if (_selfRef is not null)
{ {
try try

View File

@@ -171,6 +171,12 @@ window.auditGrid = {
// Apply a width to a <th> via a CSS custom property. The scoped stylesheet // 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. // reads --audit-col-width; absent it, the column falls back to auto.
//
// Known, intentional behaviour: during a live resize drag this updates the
// <th> width immediately, but the <td> body cells only catch up on the next
// .NET re-render (driven by OnColumnResized at pointer-up). The brief
// header/body width mismatch mid-drag is an accepted trade-off for an
// internal tool — not a bug.
_applyWidth: function (th, widthPx) { _applyWidth: function (th, widthPx) {
th.style.setProperty("--audit-col-width", widthPx + "px"); th.style.setProperty("--audit-col-width", widthPx + "px");
}, },