chore(ui): memoize AlarmSummary rows + keyboard/aria-sort on sortable headers (arch-review P4/UA6)

Claude-Session: https://claude.ai/code/session_01MtdgwpEeCUn6cUA5f1LMPj
This commit is contained in:
Joseph Doherty
2026-07-10 05:15:36 -04:00
parent 7af36fd5dc
commit 75bf14a35a
2 changed files with 170 additions and 12 deletions
@@ -109,7 +109,7 @@
<div class="row g-2 align-items-end">
<div class="col-auto">
<label class="form-label small mb-1" for="as-f-instance">Instance</label>
<select id="as-f-instance" class="form-select form-select-sm" style="min-width: 160px;" @bind="_filterInstance">
<select id="as-f-instance" class="form-select form-select-sm" style="min-width: 160px;" @bind="_filterInstance" @bind:after="RecomputeVisibleRows">
<option value="">All</option>
@foreach (var name in DistinctInstances)
{
@@ -119,7 +119,7 @@
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="as-f-kind">Kind</label>
<select id="as-f-kind" class="form-select form-select-sm" @bind="_filterKind">
<select id="as-f-kind" class="form-select form-select-sm" @bind="_filterKind" @bind:after="RecomputeVisibleRows">
<option value="">All</option>
<option value="@AlarmKind.Computed">Computed</option>
<option value="@AlarmKind.NativeOpcUa">OPC UA</option>
@@ -128,7 +128,7 @@
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="as-f-state">State</label>
<select id="as-f-state" class="form-select form-select-sm" @bind="_filterState">
<select id="as-f-state" class="form-select form-select-sm" @bind="_filterState" @bind:after="RecomputeVisibleRows">
<option value="">All</option>
<option value="@AlarmState.Active">Active</option>
<option value="@AlarmState.Normal">Normal</option>
@@ -136,7 +136,7 @@
</div>
<div class="col-auto">
<label class="form-label small mb-1" for="as-f-ack">Ack</label>
<select id="as-f-ack" class="form-select form-select-sm" @bind="_filterAck">
<select id="as-f-ack" class="form-select form-select-sm" @bind="_filterAck" @bind:after="RecomputeVisibleRows">
<option value="">Any</option>
<option value="unacked">Unacked</option>
<option value="acked">Acked</option>
@@ -146,12 +146,12 @@
<label class="form-label small mb-1" for="as-f-sev">Min severity</label>
<input id="as-f-sev" type="number" min="0" max="1000" step="50"
class="form-control form-control-sm" style="width: 110px;"
@bind="_filterMinSeverity" />
@bind="_filterMinSeverity" @bind:after="RecomputeVisibleRows" />
</div>
<div class="col-auto flex-grow-1">
<label class="form-label small mb-1" for="as-f-name">Name search</label>
<input id="as-f-name" type="text" class="form-control form-control-sm"
placeholder="alarm name contains…" @bind="_filterName" @bind:event="oninput" />
placeholder="alarm name contains…" @bind="_filterName" @bind:event="oninput" @bind:after="RecomputeVisibleRows" />
</div>
<div class="col-auto">
<button class="btn btn-outline-secondary btn-sm" @onclick="ClearFilters">Clear</button>
@@ -167,20 +167,30 @@
}
else
{
var filtered = FilteredRows().ToList();
<div class="text-muted small mb-2">Showing @filtered.Count of @_rows.Count</div>
<div class="text-muted small mb-2">Showing @_visibleRows.Count of @_rows.Count</div>
<div class="table-responsive">
<table class="table table-sm table-hover align-middle">
@* UA6 (arch-review): sortable headers are keyboard-operable — tabindex="0",
aria-sort reflects the active sort direction, and Enter/Space toggle the
sort (Space preventDefault suppresses page scroll). The same pattern should
be applied to the other custom grids in the app; that fleet-wide sweep is
deferred and logged (arch-review UA6). *@
<thead>
<tr>
<th role="button" @onclick='() => SortBy("instance")'>Instance @SortGlyph("instance")</th>
<th role="button" @onclick='() => SortBy("name")'>Alarm @SortGlyph("name")</th>
<th role="button" tabindex="0" aria-sort="@AriaSortFor("instance")"
@onclick='() => SortBy("instance")'
@onkeydown='e => OnHeaderKeyDown(e, "instance")' @onkeydown:preventDefault="_preventHeaderDefault">Instance @SortGlyph("instance")</th>
<th role="button" tabindex="0" aria-sort="@AriaSortFor("name")"
@onclick='() => SortBy("name")'
@onkeydown='e => OnHeaderKeyDown(e, "name")' @onkeydown:preventDefault="_preventHeaderDefault">Alarm @SortGlyph("name")</th>
<th>State / Kind</th>
<th role="button" class="text-end" @onclick='() => SortBy("severity")'>Severity @SortGlyph("severity")</th>
<th role="button" tabindex="0" class="text-end" aria-sort="@AriaSortFor("severity")"
@onclick='() => SortBy("severity")'
@onkeydown='e => OnHeaderKeyDown(e, "severity")' @onkeydown:preventDefault="_preventHeaderDefault">Severity @SortGlyph("severity")</th>
</tr>
</thead>
<tbody>
@foreach (var row in filtered)
@foreach (var row in _visibleRows)
{
<tr data-test="alarm-summary-row">
<td class="font-monospace small">@row.InstanceUniqueName</td>
@@ -201,6 +211,12 @@
private int? _selectedSiteId;
private IReadOnlyList<AlarmSummaryRow> _rows = Array.Empty<AlarmSummaryRow>();
// P4 (arch-review): the filtered+sorted view is memoized rather than recomputed
// on every render. RecomputeVisibleRows() refreshes it whenever the inputs change
// (a fresh snapshot in RefreshAsync, a filter change via @bind:after, or a sort).
private IReadOnlyList<AlarmSummaryRow> _visibleRows = Array.Empty<AlarmSummaryRow>();
private IReadOnlyList<string> _notReporting = Array.Empty<string>();
private AlarmRollup _rollup = new(0, 0, 0, new Dictionary<AlarmKind, int>());
private bool _loading;
@@ -266,6 +282,7 @@
_rows = result.Alarms;
_notReporting = result.NotReportingInstances;
_rollup = AlarmSummaryService.ComputeRollup(_rows);
RecomputeVisibleRows();
}
catch
{
@@ -308,6 +325,11 @@
private IEnumerable<string> DistinctInstances =>
_rows.Select(r => r.InstanceUniqueName).Distinct().OrderBy(n => n, StringComparer.OrdinalIgnoreCase);
// P4: recompute the memoized filtered+sorted view from the current snapshot and
// filter/sort inputs. Called from RefreshAsync, each filter's @bind:after, and SortBy —
// NOT per render.
private void RecomputeVisibleRows() => _visibleRows = FilteredRows().ToList();
private IEnumerable<AlarmSummaryRow> FilteredRows()
{
IEnumerable<AlarmSummaryRow> q = _rows;
@@ -366,11 +388,34 @@
_sortKey = key;
_sortDescending = key == "severity";
}
RecomputeVisibleRows();
}
private string SortGlyph(string key) =>
_sortKey != key ? "" : (_sortDescending ? "▼" : "▲");
// UA6: aria-sort token for a sortable header — ascending/descending on the active
// column, "none" otherwise (per the WAI-ARIA aria-sort value set).
private string AriaSortFor(string key) =>
_sortKey != key ? "none" : (_sortDescending ? "descending" : "ascending");
// UA6: whether the last header keydown should suppress the browser default. Bound to
// @onkeydown:preventDefault (evaluated at render), so it takes effect on the next
// Space keydown to stop the page scrolling when a header has keyboard focus.
private bool _preventHeaderDefault;
// UA6: keyboard activation for the sortable headers — Enter or Space toggles the sort,
// mirroring the pointer click.
private void OnHeaderKeyDown(KeyboardEventArgs e, string key)
{
var isSpace = e.Key is " " or "Spacebar";
_preventHeaderDefault = isSpace; // suppress Space page-scroll; leave Tab/others alone
if (e.Key == "Enter" || isSpace)
{
SortBy(key);
}
}
private void ClearFilters()
{
_filterInstance = "";
@@ -379,6 +424,7 @@
_filterAck = "";
_filterMinSeverity = null;
_filterName = "";
RecomputeVisibleRows();
}
private static string KindLabel(AlarmKind kind) => kind switch