From 9f5ec6d3cd7cb239beca39fe07d968b88e24c03d Mon Sep 17 00:00:00 2001
From: jschick04
Date: Fri, 19 Jun 2026 02:08:20 -0500
Subject: [PATCH 01/40] Add reusable Button component and migrate generic
button markup app-wide
---
.../Banner/AttentionBanner.razor | 16 +-
src/EventLogExpert.UI/Banner/BannerHost.razor | 26 +--
.../Banner/CriticalBanner.razor | 12 +-
.../Banner/CriticalBanner.razor.cs | 5 +-
.../Banner/ErrorBanner.razor | 17 +-
.../Banner/ExportProgressBanner.razor | 7 +-
src/EventLogExpert.UI/Banner/InfoBanner.razor | 10 +-
.../Banner/UpgradeProgressBanner.razor | 7 +-
.../Dashboard/EmptyStateDashboard.razor | 2 +-
.../Database/DatabaseEntryRow.razor | 68 +++---
.../Database/DatabaseEntryRow.razor.css | 12 +-
.../Database/DatabaseRecoveryModal.razor | 14 +-
.../DatabaseTools/DatabaseToolsLogView.razor | 12 +-
.../Tabs/CreateDatabaseTab.razor | 24 +-
.../DatabaseTools/Tabs/DiffDatabasesTab.razor | 18 +-
.../Tabs/ManageDatabasesTab.razor | 56 ++---
.../Tabs/ManageDatabasesTab.razor.cs | 23 +-
.../Tabs/ManageDatabasesTab.razor.css | 2 +-
.../DatabaseTools/Tabs/MergeDatabaseTab.razor | 16 +-
.../DatabaseTools/Tabs/ShowProvidersTab.razor | 20 +-
.../Tabs/UpgradeDatabaseTab.razor | 14 +-
.../DebugLog/DebugLogModal.razor | 22 +-
.../Editing/FilterEditPanel.razor | 41 ++--
.../Editing/FilterEditPanel.razor.css | 14 +-
.../Editing/FilterPredicateEditor.razor | 26 +--
.../Editing/FilterPredicateList.razor | 17 +-
.../Editing/FilterPredicateList.razor.cs | 6 +-
.../Editing/FilterPredicateList.razor.css | 4 +-
.../FilterEditor/Rows/FilterRowActions.razor | 38 ++--
.../Rows/FilterRowActions.razor.cs | 5 +-
.../FilterEditor/Rows/FilterRowHeader.razor | 13 +-
.../Rows/FilterRowHeader.razor.css | 8 +-
.../FilterLibrary/FilterLibraryModal.razor | 15 +-
.../LibraryEntryFilterEditor.razor | 6 +-
.../LibraryEntryFilterEditor.razor.css | 2 +-
.../FilterLibrary/LibraryEntryRow.razor | 82 +++----
.../FilterLibrary/LibraryEntryRow.razor.cs | 12 +-
.../FilterLibrary/LibraryEntryRow.razor.css | 18 +-
.../FilterLibrary/LibrarySavedTabHeader.razor | 6 +-
.../LibrarySavedTabHeader.razor.css | 15 --
.../FilterLibrary/TagManagementPanel.razor | 78 +++----
.../FilterPane/FilterPane.razor | 143 ++++++------
.../FilterPane/FilterPane.razor.css | 4 +-
src/EventLogExpert.UI/Inputs/Button.cs | 9 +
src/EventLogExpert.UI/Inputs/ButtonBase.cs | 75 +++++++
src/EventLogExpert.UI/Inputs/DangerButton.cs | 9 +
src/EventLogExpert.UI/Inputs/PrimaryButton.cs | 9 +
src/EventLogExpert.UI/Inputs/WarningButton.cs | 9 +
src/EventLogExpert.UI/Modal/ModalChrome.razor | 119 +++++-----
.../Modal/ModalChrome.razor.cs | 8 +-
.../Modal/ModalChrome.razor.css | 2 +-
src/EventLogExpert.UI/wwwroot/app.css | 15 ++
.../Inputs/ButtonTests.cs | 206 ++++++++++++++++++
53 files changed, 827 insertions(+), 590 deletions(-)
create mode 100644 src/EventLogExpert.UI/Inputs/Button.cs
create mode 100644 src/EventLogExpert.UI/Inputs/ButtonBase.cs
create mode 100644 src/EventLogExpert.UI/Inputs/DangerButton.cs
create mode 100644 src/EventLogExpert.UI/Inputs/PrimaryButton.cs
create mode 100644 src/EventLogExpert.UI/Inputs/WarningButton.cs
create mode 100644 tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
diff --git a/src/EventLogExpert.UI/Banner/AttentionBanner.razor b/src/EventLogExpert.UI/Banner/AttentionBanner.razor
index dc947f951..d106b9308 100644
--- a/src/EventLogExpert.UI/Banner/AttentionBanner.razor
+++ b/src/EventLogExpert.UI/Banner/AttentionBanner.razor
@@ -3,16 +3,12 @@
@CycleNav
-
-
-
-
+
diff --git a/src/EventLogExpert.UI/Banner/BannerHost.razor b/src/EventLogExpert.UI/Banner/BannerHost.razor
index 49ff65a05..b560660b0 100644
--- a/src/EventLogExpert.UI/Banner/BannerHost.razor
+++ b/src/EventLogExpert.UI/Banner/BannerHost.razor
@@ -18,20 +18,18 @@
@if (showCyclePagination)
{
-
-
-
-
-
-
+
+
+
+
}
;
diff --git a/src/EventLogExpert.UI/Banner/CriticalBanner.razor b/src/EventLogExpert.UI/Banner/CriticalBanner.razor
index 374c7d9e0..337de69a8 100644
--- a/src/EventLogExpert.UI/Banner/CriticalBanner.razor
+++ b/src/EventLogExpert.UI/Banner/CriticalBanner.razor
@@ -2,15 +2,15 @@
An unexpected error occurred: @Critical.GetType().Name: @Critical.Message
-
+
Reload
-
-
+
+
Relaunch
-
- OnCopyDetailsClickedAsync(Critical)" type="button">
+
+
Copy details
-
+
diff --git a/src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs b/src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs
index 46b92d8ee..1e676a8c9 100644
--- a/src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs
+++ b/src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs
@@ -5,6 +5,7 @@
using EventLogExpert.Runtime.Banner;
using EventLogExpert.Runtime.Common.Clipboard;
using EventLogExpert.Runtime.Common.Restart;
+using EventLogExpert.UI.Inputs;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
@@ -14,7 +15,7 @@ public sealed partial class CriticalBanner : ComponentBase, IDisposable
{
private CancellationTokenSource? _copiedFeedbackCts;
private string? _recoveryFailureMessage;
- private ElementReference _reloadButtonRef;
+ private Button? _reloadButton;
private string? _restartFailureMessage;
private bool _showCopiedFeedback;
@@ -42,7 +43,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
{
try
{
- await _reloadButtonRef.FocusAsync();
+ await (_reloadButton?.FocusAsync() ?? ValueTask.CompletedTask);
}
catch (JSDisconnectedException) { /* Circuit gone — nothing to focus. */ }
catch (TaskCanceledException) { /* Focus cancelled mid-render; harmless. */ }
diff --git a/src/EventLogExpert.UI/Banner/ErrorBanner.razor b/src/EventLogExpert.UI/Banner/ErrorBanner.razor
index 170128d64..5d280d7c9 100644
--- a/src/EventLogExpert.UI/Banner/ErrorBanner.razor
+++ b/src/EventLogExpert.UI/Banner/ErrorBanner.razor
@@ -5,17 +5,14 @@
@if (Entry is { ActionLabel: { } actionLabel, Action: { } action })
{
-
OnErrorActionClickedAsync(action)"
- type="button">
+
@actionLabel
-
+
}
-
-
-
+
diff --git a/src/EventLogExpert.UI/Banner/ExportProgressBanner.razor b/src/EventLogExpert.UI/Banner/ExportProgressBanner.razor
index 490ba3b10..4ccde8894 100644
--- a/src/EventLogExpert.UI/Banner/ExportProgressBanner.razor
+++ b/src/EventLogExpert.UI/Banner/ExportProgressBanner.razor
@@ -5,9 +5,8 @@
@CycleNav
-
OnCancelExportClickedAsync(Export)"
- type="button">
+
Cancel
-
+
diff --git a/src/EventLogExpert.UI/Banner/InfoBanner.razor b/src/EventLogExpert.UI/Banner/InfoBanner.razor
index 4d34b0418..483a1c524 100644
--- a/src/EventLogExpert.UI/Banner/InfoBanner.razor
+++ b/src/EventLogExpert.UI/Banner/InfoBanner.razor
@@ -3,10 +3,8 @@
@CycleNav
-
-
-
+
diff --git a/src/EventLogExpert.UI/Banner/UpgradeProgressBanner.razor b/src/EventLogExpert.UI/Banner/UpgradeProgressBanner.razor
index 37947b1d5..5faefc5c0 100644
--- a/src/EventLogExpert.UI/Banner/UpgradeProgressBanner.razor
+++ b/src/EventLogExpert.UI/Banner/UpgradeProgressBanner.razor
@@ -17,9 +17,8 @@
@CycleNav
-
OnCancelUpgradeClickedAsync(Progress)"
- type="button">
+
Cancel
-
+
diff --git a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor
index a4b0b42ed..57a2370c3 100644
--- a/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor
+++ b/src/EventLogExpert.UI/Dashboard/EmptyStateDashboard.razor
@@ -15,7 +15,7 @@
A filter is still applied and will affect your next open.
- Clear filter
+ Clear filter
}
diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor
index 68765bf1f..abae970b4 100644
--- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor
+++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor
@@ -57,9 +57,9 @@
for @Entry.FileName
}
-
+
Cancel
-
+
}
else
@@ -73,51 +73,51 @@
break;
case ActionKind.Upgrade:
-
- {
- if (!IsUpgradeBlocked) { await OnUpgrade.InvokeAsync(); }
- })"
- type="button">
+
Upgrade
-
+
+
break;
case ActionKind.Retry:
-
- {
- if (!IsUpgradeBlocked) { await OnUpgrade.InvokeAsync(); }
- })"
- type="button">
+
Retry Upgrade
-
+
+
break;
case ActionKind.RestoreFromBackup:
-
- {
- if (!IsRestoreBlocked) { await OnRestoreFromBackup.InvokeAsync(); }
- })"
- type="button">
+
Restore
-
+
+
break;
case ActionKind.RetryClassification:
- await OnRetryClassification.InvokeAsync())"
- type="button">
+
Retry classification
-
+
+
break;
}
diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css
index 15778f61b..8488f7aa0 100644
--- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css
+++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css
@@ -91,7 +91,7 @@
gap: .5rem;
}
-.db-entry-upgrade-btn {
+::deep .db-entry-upgrade-btn {
white-space: nowrap;
}
@@ -128,7 +128,7 @@
animation: db-entry-spin 1.5s linear infinite;
}
-.db-entry-cancel-btn {
+::deep .db-entry-cancel-btn {
flex: 0 0 auto;
padding: .15rem .65rem;
@@ -147,8 +147,12 @@
@media (prefers-reduced-motion: reduce) {
.db-entry-row,
.db-entry-upgrading,
- .db-entry-upgrading-text,
- .db-entry-cancel-btn {
+ .db-entry-upgrading-text {
+ animation: none;
+ transition: none;
+ }
+
+ ::deep .db-entry-cancel-btn {
animation: none;
transition: none;
}
diff --git a/src/EventLogExpert.UI/Database/DatabaseRecoveryModal.razor b/src/EventLogExpert.UI/Database/DatabaseRecoveryModal.razor
index 458de1e6b..62d4fe993 100644
--- a/src/EventLogExpert.UI/Database/DatabaseRecoveryModal.razor
+++ b/src/EventLogExpert.UI/Database/DatabaseRecoveryModal.razor
@@ -23,18 +23,12 @@
-
+
Restore all
-
-
+
+
Delete all
-
+
diff --git a/src/EventLogExpert.UI/DatabaseTools/DatabaseToolsLogView.razor b/src/EventLogExpert.UI/DatabaseTools/DatabaseToolsLogView.razor
index e929c24d1..988bcf102 100644
--- a/src/EventLogExpert.UI/DatabaseTools/DatabaseToolsLogView.razor
+++ b/src/EventLogExpert.UI/DatabaseTools/DatabaseToolsLogView.razor
@@ -17,19 +17,19 @@
}
-
+
Copy
-
+
-
+
Export
-
+
@if (_showJumpToLatest)
{
-
+
Jump to latest
-
+
}
diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/CreateDatabaseTab.razor b/src/EventLogExpert.UI/DatabaseTools/Tabs/CreateDatabaseTab.razor
index 70c22ed87..2f1dc65da 100644
--- a/src/EventLogExpert.UI/DatabaseTools/Tabs/CreateDatabaseTab.razor
+++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/CreateDatabaseTab.razor
@@ -15,7 +15,7 @@
placeholder="C:\path\to\new-database.db"
type="text"
value="@_targetPath" />
- Browse…
+ Browse…
@@ -33,7 +33,7 @@
placeholder="Leave empty for local providers, or pick / paste a path"
type="text"
value="@_sourcePath" />
- Browse…
+ Browse…
@@ -73,7 +73,7 @@
placeholder="Optional — path to .db or .evtx whose providers are excluded"
type="text"
value="@_skipPath" />
- Browse…
+ Browse…
@@ -83,7 +83,11 @@
Running without elevation — some local providers (Security, etc.) will be skipped or return incomplete metadata.
- Run Elevated
+
+ Run Elevated
+
}
@@ -95,22 +99,22 @@
{
@if (IsCancelling)
{
-
+
Cancelling…
-
+
}
else
{
-
+
Cancel
-
+
}
}
else
{
-
+
Run
-
+
}
diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/DiffDatabasesTab.razor b/src/EventLogExpert.UI/DatabaseTools/Tabs/DiffDatabasesTab.razor
index ef599a352..3a06acf98 100644
--- a/src/EventLogExpert.UI/DatabaseTools/Tabs/DiffDatabasesTab.razor
+++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/DiffDatabasesTab.razor
@@ -15,7 +15,7 @@
placeholder="Path to .db or .evtx (paste a folder path to use a directory)"
type="text"
value="@_firstPath" />
- Browse…
+ Browse…
@@ -31,7 +31,7 @@
placeholder="Path to .db or .evtx (paste a folder path to use a directory)"
type="text"
value="@_secondPath" />
- Browse…
+ Browse…
@@ -52,7 +52,7 @@
placeholder="C:\path\to\new-diff-database.db"
type="text"
value="@_newDbPath" />
- Browse…
+ Browse…
@@ -63,22 +63,22 @@
{
@if (IsCancelling)
{
-
+
Cancelling…
-
+
}
else
{
-
+
Cancel
-
+
}
}
else
{
-
+
Run
-
+
}
diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor
index a232b0802..fc7910698 100644
--- a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor
+++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor
@@ -14,23 +14,21 @@
@if (DatabaseService.Entries.Count > 0)
{
-
+ OnClick="ToggleSelectionMode"
+ @ref="_selectButton">
@(_isSelectionModeActive ? "Done" : "Select")
-
+
}
-
+ OnClick="ImportDatabase"
+ @ref="_importButton">
Import database…
-
+
@if (_isSelectionModeActive && DatabaseService.Entries.Count > 0)
@@ -97,21 +95,17 @@
@if (_eligibleUpgradeCount > 0)
{
-
+
Upgrade @_eligibleUpgradeCount
-
+
}
-
+
Remove @_selectedForBulk.Count
-
+
@(IsUpgradeBlocked
? "Cannot upgrade: another upgrade is already in progress."
@@ -126,19 +120,15 @@
@_pendingToggles.Count pending change@(_pendingToggles.Count == 1 ? "" : "s")
-
+
Discard
-
-
+
+ OnClick="OnSaveClickAsync">
Save changes
-
+
@(IsUpgradeBlocked ? "Cannot save: a database upgrade is in progress." : string.Empty)
diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs
index 84318c23e..d4c23cc84 100644
--- a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs
+++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs
@@ -10,6 +10,7 @@
using EventLogExpert.Runtime.EventLog;
using EventLogExpert.UI.Common;
using EventLogExpert.UI.Database;
+using EventLogExpert.UI.Inputs;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using System.Collections.Immutable;
@@ -27,19 +28,19 @@ public sealed partial class ManageDatabasesTab : ComponentBase, IAsyncDisposable
private readonly HashSet _selectedForBulk = new(StringComparer.OrdinalIgnoreCase);
private readonly string _upgradeBlockedHelpId = ComponentId.NewUnique("manage-upgrade-blocked").Value;
- private ElementReference _bulkRemoveButtonRef;
- private ElementReference _bulkUpgradeButtonRef;
+ private DangerButton? _bulkRemoveButton;
+ private PrimaryButton? _bulkUpgradeButton;
private CancellationTokenSource? _classificationObservationCts;
private volatile bool _disposed;
private int _eligibleUpgradeCount;
private (string FileName, FocusTarget Target)? _focusRestorationTarget;
- private ElementReference _importButtonRef;
+ private Button? _importButton;
private ImmutableHashSet _initialActiveSnapshot = ImmutableHashSet.Empty;
private bool _isSelectionModeActive;
private ElementReference _masterCheckboxRef;
private bool _restorationOccurred;
private bool _schemaUpgradeOccurred;
- private ElementReference _selectButtonRef;
+ private Button? _selectButton;
private string _selectionAnnouncement = string.Empty;
private enum FocusTarget
@@ -126,7 +127,7 @@ public async Task ExitSelectionModeWithFocusAsync()
ExitSelectionMode();
StateHasChanged();
- try { await _selectButtonRef.FocusAsync(preventScroll: true); }
+ try { await (_selectButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask); }
catch (ObjectDisposedException) { }
catch (JSDisconnectedException) { }
catch (JSException) { }
@@ -182,9 +183,9 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
FocusTarget.SameRowName when !string.IsNullOrEmpty(target.FileName) =>
FocusEntryRowNameAsync(target.FileName),
FocusTarget.BulkRemoveButton when HasBulkSelection =>
- _bulkRemoveButtonRef.FocusAsync(preventScroll: true),
+ _bulkRemoveButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask,
FocusTarget.ImportButton =>
- _importButtonRef.FocusAsync(preventScroll: true),
+ _importButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask,
_ => ValueTask.CompletedTask
});
}
@@ -556,7 +557,7 @@ private async Task FocusAfterBulkUpgradeAsync()
// button so keyboard users have a stable anchor.
if (!_isSelectionModeActive)
{
- try { await _selectButtonRef.FocusAsync(preventScroll: true); }
+ try { await (_selectButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask); }
catch (ObjectDisposedException) { }
catch (JSDisconnectedException) { }
catch (JSException) { }
@@ -576,7 +577,7 @@ private async Task FocusAfterBulkUpgradeAsync()
return;
}
- try { await _selectButtonRef.FocusAsync(preventScroll: true); }
+ try { await (_selectButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask); }
catch (ObjectDisposedException) { }
catch (JSDisconnectedException) { }
catch (JSException) { }
@@ -595,7 +596,7 @@ private async ValueTask FocusEntryRowNameAsync(string fileName)
return;
}
- try { await _importButtonRef.FocusAsync(preventScroll: true); }
+ try { await (_importButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask); }
catch (ObjectDisposedException) { }
catch (JSDisconnectedException) { }
catch (JSException) { }
@@ -782,7 +783,7 @@ private async Task OnBulkUpgradeClickAsync()
{
if (_disposed) { return; }
- try { await _bulkUpgradeButtonRef.FocusAsync(preventScroll: true); }
+ try { await (_bulkUpgradeButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask); }
catch (ObjectDisposedException) { }
catch (JSDisconnectedException) { }
catch (JSException) { }
diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.css b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.css
index 556f32035..a153488e4 100644
--- a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.css
+++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.css
@@ -14,7 +14,7 @@
padding: .5rem 1rem;
}
-.manage-databases-import-btn {
+::deep .manage-databases-import-btn {
margin-left: auto;
}
diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/MergeDatabaseTab.razor b/src/EventLogExpert.UI/DatabaseTools/Tabs/MergeDatabaseTab.razor
index 7d66b1784..7a7fd8889 100644
--- a/src/EventLogExpert.UI/DatabaseTools/Tabs/MergeDatabaseTab.razor
+++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/MergeDatabaseTab.razor
@@ -17,7 +17,7 @@
placeholder="Path to .db or .evtx (paste a folder path to use a directory)"
type="text"
value="@_sourcePath" />
- Browse…
+ Browse…
@@ -33,7 +33,7 @@
placeholder="C:\path\to\existing-database.db"
type="text"
value="@_targetPath" />
- Browse…
+ Browse…
@@ -56,22 +56,22 @@
{
@if (IsCancelling)
{
-
+
Cancelling…
-
+
}
else
{
-
+
Cancel
-
+
}
}
else
{
-
+
Run
-
+
}
diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/ShowProvidersTab.razor b/src/EventLogExpert.UI/DatabaseTools/Tabs/ShowProvidersTab.razor
index 1cbc91b3e..1ccc2ef61 100644
--- a/src/EventLogExpert.UI/DatabaseTools/Tabs/ShowProvidersTab.razor
+++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/ShowProvidersTab.razor
@@ -17,7 +17,7 @@
id="show-source-path"
placeholder="Leave empty for local providers, or pick / paste a path"
type="text" />
- Browse…
+ Browse…
@@ -51,7 +51,11 @@
Running without elevation — some local providers (Security, etc.) will be skipped or return incomplete metadata.
- Run Elevated
+
+ Run Elevated
+
}
@@ -63,22 +67,22 @@
{
@if (IsCancelling)
{
-
+
Cancelling…
-
+
}
else
{
-
+
Cancel
-
+
}
}
else
{
-
+
Run
-
+
}
diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/UpgradeDatabaseTab.razor b/src/EventLogExpert.UI/DatabaseTools/Tabs/UpgradeDatabaseTab.razor
index 98d72c4de..19bc719ba 100644
--- a/src/EventLogExpert.UI/DatabaseTools/Tabs/UpgradeDatabaseTab.razor
+++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/UpgradeDatabaseTab.razor
@@ -15,7 +15,7 @@
placeholder="C:\path\to\database.db"
type="text"
value="@_dbPath" />
- Browse…
+ Browse…
@@ -26,22 +26,22 @@
{
@if (IsCancelling)
{
-
+
Cancelling…
-
+
}
else
{
-
+
Cancel
-
+
}
}
else
{
-
+
Run
-
+
}
diff --git a/src/EventLogExpert.UI/DebugLog/DebugLogModal.razor b/src/EventLogExpert.UI/DebugLog/DebugLogModal.razor
index c01448c8b..243e80889 100644
--- a/src/EventLogExpert.UI/DebugLog/DebugLogModal.razor
+++ b/src/EventLogExpert.UI/DebugLog/DebugLogModal.razor
@@ -61,7 +61,7 @@
value="@_pendingStringFilter" />
-
+
@line
@@ -77,25 +77,21 @@
diff --git a/src/EventLogExpert.UI/FilterEditor/Editing/FilterEditPanel.razor b/src/EventLogExpert.UI/FilterEditor/Editing/FilterEditPanel.razor
index 13b1a3604..8a86a7394 100644
--- a/src/EventLogExpert.UI/FilterEditor/Editing/FilterEditPanel.razor
+++ b/src/EventLogExpert.UI/FilterEditor/Editing/FilterEditPanel.razor
@@ -3,15 +3,14 @@
-
OnExclusionChanged.InvokeAsync(!filter.IsExcluded))"
- title="@(filter.IsExcluded ? "Excluded \u2014 click to include" : "Included \u2014 click to exclude")"
- type="button">
-
-
+ IconClass="@(filter.IsExcluded ? "bi bi-slash-circle-fill" : "bi bi-slash-circle")"
+ IconOnly
+ OnClick="@(() => OnExclusionChanged.InvokeAsync(!filter.IsExcluded))"
+ title="@(filter.IsExcluded ? "Excluded \u2014 click to include" : "Included \u2014 click to exclude")" />
-
+
Save
-
+
-
-
-
+
@if (!IsPending)
{
-
-
-
+
}
diff --git a/src/EventLogExpert.UI/FilterEditor/Editing/FilterEditPanel.razor.css b/src/EventLogExpert.UI/FilterEditor/Editing/FilterEditPanel.razor.css
index c1f2f8dbb..6be3e4181 100644
--- a/src/EventLogExpert.UI/FilterEditor/Editing/FilterEditPanel.razor.css
+++ b/src/EventLogExpert.UI/FilterEditor/Editing/FilterEditPanel.razor.css
@@ -42,7 +42,7 @@
so the whole action cluster appears vertically aligned. Scoped to
FilterEditPanel actions only; other .button-green call sites elsewhere in
the app stay ghost-styled. */
-.filter-edit-panel-actions button.button-green {
+.filter-edit-panel-actions ::deep button.button-green {
display: inline-flex;
align-items: center;
gap: 0.25rem;
@@ -55,33 +55,33 @@
border-radius: 4px;
}
-.filter-edit-panel-actions button.button-green:hover {
+.filter-edit-panel-actions ::deep button.button-green:hover {
filter: brightness(1.1);
}
-.filter-edit-panel-actions button.button-green:focus-visible {
+.filter-edit-panel-actions ::deep button.button-green:focus-visible {
outline: 2px solid var(--clr-green);
outline-offset: 2px;
}
/* Leftmost icon toggle for include/exclude. Slash-circle icon = excluded state;
muted gray = included. Uses aria-pressed for SR + visible color cue. */
-.filter-exclude-toggle {
+.filter-edit-panel-content ::deep .filter-exclude-toggle {
color: var(--text-secondary);
transition: color 0.1s ease-in-out;
}
-.filter-exclude-toggle[data-excluded="true"] {
+.filter-edit-panel-content ::deep .filter-exclude-toggle[data-excluded="true"] {
color: var(--clr-red);
}
-.filter-exclude-toggle:focus-visible {
+.filter-edit-panel-content ::deep .filter-exclude-toggle:focus-visible {
outline: 2px solid var(--toggle-accent);
outline-offset: 2px;
}
@media (forced-colors: active) {
- .filter-exclude-toggle[data-excluded="true"] {
+ .filter-edit-panel-content ::deep .filter-exclude-toggle[data-excluded="true"] {
color: Highlight;
}
}
diff --git a/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateEditor.razor b/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateEditor.razor
index d1b67b576..43a051f86 100644
--- a/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateEditor.razor
+++ b/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateEditor.razor
@@ -15,22 +15,18 @@
Id="@ComponentId.For(Value.Id, ComponentIdScope.Predicate).Value"
OnChanged="OnComparisonChanged" />
-
-
-
+
-
-
-
+
}
else
diff --git a/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateList.razor b/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateList.razor
index e150b5d39..6518765d2 100644
--- a/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateList.razor
+++ b/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateList.razor
@@ -12,14 +12,13 @@
Value="predicate" />
}
-
-
-
+
diff --git a/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateList.razor.cs b/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateList.razor.cs
index f883d7845..0d032d805 100644
--- a/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateList.razor.cs
+++ b/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateList.razor.cs
@@ -4,6 +4,7 @@
using EventLogExpert.Filtering.Drafts;
using EventLogExpert.Filtering.Persistence;
using EventLogExpert.UI.Focus;
+using EventLogExpert.UI.Inputs;
using Microsoft.AspNetCore.Components;
namespace EventLogExpert.UI.FilterEditor.Editing;
@@ -12,7 +13,7 @@ public sealed partial class FilterPredicateList : ComponentBase
{
private readonly Dictionary
_editorRefs = new();
- private ElementReference _addButtonRef;
+ private Button? _addButton;
private FilterId? _editingPredicateId;
private bool _focusAddButtonAfterRender;
private FilterId? _focusChipAfterRender;
@@ -55,7 +56,8 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
else if (_focusAddButtonAfterRender)
{
_focusAddButtonAfterRender = false;
- await ElementFocus.SafelyAsync(_addButtonRef);
+
+ if (_addButton is { } button) { await ElementFocus.SafelyAsync(button.Element); }
}
await base.OnAfterRenderAsync(firstRender);
diff --git a/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateList.razor.css b/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateList.razor.css
index ca2c3d204..5046b80bd 100644
--- a/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateList.razor.css
+++ b/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateList.razor.css
@@ -17,10 +17,10 @@
min-width: 0;
}
-.add-filter-predicate-button {
+.filter-predicate-list ::deep .add-filter-predicate-button {
color: var(--text-secondary);
}
-.add-filter-predicate-button:hover {
+.filter-predicate-list ::deep .add-filter-predicate-button:hover {
color: var(--clr-lightblue);
}
diff --git a/src/EventLogExpert.UI/FilterEditor/Rows/FilterRowActions.razor b/src/EventLogExpert.UI/FilterEditor/Rows/FilterRowActions.razor
index f6572bf69..8f611b8a9 100644
--- a/src/EventLogExpert.UI/FilterEditor/Rows/FilterRowActions.razor
+++ b/src/EventLogExpert.UI/FilterEditor/Rows/FilterRowActions.razor
@@ -3,30 +3,24 @@
@if (ShowScenarioCopy)
{
- CopyScenarioJsonAsync(savedFilter))"
- title="Copy as scenario JSON"
- type="button">
-
-
+
}
-
-
-
+
-
-
-
+
Enable filter
diff --git a/src/EventLogExpert.UI/FilterEditor/Rows/FilterRowActions.razor.cs b/src/EventLogExpert.UI/FilterEditor/Rows/FilterRowActions.razor.cs
index 9616f343d..cb495c3bf 100644
--- a/src/EventLogExpert.UI/FilterEditor/Rows/FilterRowActions.razor.cs
+++ b/src/EventLogExpert.UI/FilterEditor/Rows/FilterRowActions.razor.cs
@@ -4,6 +4,7 @@
using EventLogExpert.Filtering.Persistence;
using EventLogExpert.UI.Common;
using EventLogExpert.UI.Focus;
+using EventLogExpert.UI.Inputs;
using Microsoft.AspNetCore.Components;
namespace EventLogExpert.UI.FilterEditor.Rows;
@@ -12,7 +13,7 @@ public sealed partial class FilterRowActions : ComponentBase
{
private readonly string _enableToggleLabelId = ComponentId.NewUnique("filter-row-toggle").Value;
- private ElementReference _editButtonRef;
+ private Button? _editButton;
[CascadingParameter] public ScenarioAuthoringRowContext? AuthoringContext { get; set; }
@@ -33,7 +34,7 @@ public sealed partial class FilterRowActions : ComponentBase
private bool ShowScenarioCopy => AuthoringContext is { Enabled: true };
- internal ValueTask FocusEditAsync() => ElementFocus.SafelyAsync(_editButtonRef);
+ internal ValueTask FocusEditAsync() => _editButton is { } button ? ElementFocus.SafelyAsync(button.Element) : ValueTask.CompletedTask;
private static string DescribeFilter(SavedFilter filter) =>
string.IsNullOrWhiteSpace(filter.ComparisonText) ? "filter" : $"filter '{filter.ComparisonText}'";
diff --git a/src/EventLogExpert.UI/FilterEditor/Rows/FilterRowHeader.razor b/src/EventLogExpert.UI/FilterEditor/Rows/FilterRowHeader.razor
index 029cea9b7..196dda049 100644
--- a/src/EventLogExpert.UI/FilterEditor/Rows/FilterRowHeader.razor
+++ b/src/EventLogExpert.UI/FilterEditor/Rows/FilterRowHeader.razor
@@ -1,15 +1,14 @@
@if (Value is { } savedFilter)
{
}
diff --git a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryFilterEditor.razor.css b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryFilterEditor.razor.css
index 4f00d463a..de6eb0d66 100644
--- a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryFilterEditor.razor.css
+++ b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryFilterEditor.razor.css
@@ -16,7 +16,7 @@
font-style: italic;
}
-.library-entry-filter-editor-add-button {
+::deep .library-entry-filter-editor-add-button {
align-self: flex-start;
margin-top: .25rem;
}
diff --git a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor
index b88fdbb46..13c200f41 100644
--- a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor
+++ b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor
@@ -4,14 +4,13 @@
@if (Entry is LibraryEntryFilterSet)
{
-
-
-
+ CssClass="library-entry-expand-toggle"
+ IconClass="@(_isExpanded ? "bi bi-chevron-down" : "bi bi-chevron-right")"
+ IconOnly
+ OnClick="ToggleExpand" />
}
@@ -63,54 +62,46 @@
@if (inlineHidden > 0)
{
-
+
+@inlineHidden more
-
+
}
}
-
-
-
+
}
-
+
Apply
-
+
@if (IsFavoritable)
{
-
-
-
+ IconClass="@FavoriteIconClass"
+ IconOnly
+ OnClick="OnToggleFavoriteAsync"
+ title="@FavoriteTitle" />
}
-
-
-
+ IconClass="bi bi-three-dots-vertical"
+ IconOnly
+ OnClick="ToggleMoreMenuAsync"
+ @ref="_moreMenuButton"
+ title="More actions" />
@if (_isEditingTags)
@@ -121,13 +112,12 @@
Value="@Entry.Tags"
ValueChanged="@OnTagsChangedAsync" />
-
-
-
+
}
diff --git a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.cs b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.cs
index 4e00dac85..d7d4581a9 100644
--- a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.cs
+++ b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.cs
@@ -9,6 +9,7 @@
using EventLogExpert.UI.Common;
using EventLogExpert.UI.Common.Interop;
using EventLogExpert.UI.Focus;
+using EventLogExpert.UI.Inputs;
using Fluxor;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
@@ -27,7 +28,7 @@ public sealed partial class LibraryEntryRow : ComponentBase, IAsyncDisposable
private bool _isEditingTags;
private bool _isExpanded;
private IJSObjectReference? _menuAnchorModule;
- private ElementReference _moreMenuButtonRef;
+ private Button? _moreMenuButton;
private long _moreMenuId;
private bool _pendingFocusMoreButton;
private bool _pendingScrollEditIntoView;
@@ -113,7 +114,10 @@ public async ValueTask DisposeAsync()
_menuAnchorModule = null;
}
- internal ValueTask FocusMoreActionsButtonAsync() => ElementFocus.TrySafelyAsync(_moreMenuButtonRef);
+ internal ValueTask FocusMoreActionsButtonAsync() =>
+ _moreMenuButton is { } button ?
+ ElementFocus.TrySafelyAsync(button.Element) :
+ ValueTask.FromResult(false);
protected override async Task OnAfterRenderAsync(bool firstRender)
{
@@ -410,12 +414,14 @@ private async Task ToggleMoreMenuAsync()
{
if (IsMoreMenuOpen) { MenuService.Close(); return; }
+ if (_moreMenuButton is not { } moreMenuButton) { return; }
+
try
{
_menuAnchorModule ??= await JSRuntime.InvokeAsync(
"import", "./_content/EventLogExpert.UI/Menu/MenuAnchor.js");
- var rect = await _menuAnchorModule.InvokeAsync("getMenuElementRect", _moreMenuButtonRef);
+ var rect = await _menuAnchorModule.InvokeAsync("getMenuElementRect", moreMenuButton.Element);
MenuService.OpenAt(rect.Left, rect.Bottom, BuildMoreMenu(), focusFirst: true);
_moreMenuId = MenuService.ActiveMenuId;
StateHasChanged();
diff --git a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.css b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.css
index 441cb5d03..36611ddc1 100644
--- a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.css
+++ b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.css
@@ -22,7 +22,7 @@
font-size: 1rem;
}
-.library-entry-expand-toggle {
+::deep .library-entry-expand-toggle {
flex-shrink: 0;
}
@@ -137,7 +137,7 @@
outline-offset: 1px;
}
-.library-entry-tag-chip-more {
+::deep .library-entry-tag-chip-more {
flex: 0 0 auto;
padding: 0 .375rem;
font-size: .75rem;
@@ -147,12 +147,12 @@
border-radius: .2rem;
}
-.library-entry-tag-chip-more:hover {
+::deep .library-entry-tag-chip-more:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
}
-.library-entry-tag-add-inline {
+::deep .library-entry-tag-add-inline {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -169,23 +169,23 @@
transition: background 120ms ease, color 120ms ease;
}
-.library-entry-tag-add-inline > .bi {
+::deep .library-entry-tag-add-inline > .bi {
font-size: 14px;
line-height: 1;
}
-.library-entry-tag-add-inline:hover,
-.library-entry-tag-add-inline:focus-visible {
+::deep .library-entry-tag-add-inline:hover,
+::deep .library-entry-tag-add-inline:focus-visible {
color: var(--clr-statusbar);
background: color-mix(in srgb, var(--clr-statusbar) 10%, transparent);
}
-.library-entry-tag-add-inline:focus-visible {
+::deep .library-entry-tag-add-inline:focus-visible {
outline: 2px solid var(--clr-statusbar);
outline-offset: 1px;
}
-.library-entry-tags-done {
+::deep .library-entry-tags-done {
padding: 0 .25rem;
flex-shrink: 0;
}
diff --git a/src/EventLogExpert.UI/FilterLibrary/LibrarySavedTabHeader.razor b/src/EventLogExpert.UI/FilterLibrary/LibrarySavedTabHeader.razor
index 5b3fa3148..a9598ffea 100644
--- a/src/EventLogExpert.UI/FilterLibrary/LibrarySavedTabHeader.razor
+++ b/src/EventLogExpert.UI/FilterLibrary/LibrarySavedTabHeader.razor
@@ -1,10 +1,8 @@
@if (!_isExpanded)
{
-
+
New saved filter
-
+
}
else
{
diff --git a/src/EventLogExpert.UI/FilterLibrary/LibrarySavedTabHeader.razor.css b/src/EventLogExpert.UI/FilterLibrary/LibrarySavedTabHeader.razor.css
index c1523c8ed..30de4df1b 100644
--- a/src/EventLogExpert.UI/FilterLibrary/LibrarySavedTabHeader.razor.css
+++ b/src/EventLogExpert.UI/FilterLibrary/LibrarySavedTabHeader.razor.css
@@ -1,18 +1,3 @@
-.library-saved-tab-new-button {
- width: 100%;
- height: var(--control-height);
- padding: 0 .5rem;
- text-align: left;
- border: 1px solid var(--clr-statusbar);
- border-radius: .25rem;
- color: var(--text-primary);
- background: transparent;
-}
-
-.library-saved-tab-new-button:hover {
- background: color-mix(in srgb, var(--clr-statusbar) 10%, transparent);
-}
-
.library-saved-tab-new-draft {
display: flex;
flex-direction: column;
diff --git a/src/EventLogExpert.UI/FilterLibrary/TagManagementPanel.razor b/src/EventLogExpert.UI/FilterLibrary/TagManagementPanel.razor
index 3962bc8c7..77133e522 100644
--- a/src/EventLogExpert.UI/FilterLibrary/TagManagementPanel.razor
+++ b/src/EventLogExpert.UI/FilterLibrary/TagManagementPanel.razor
@@ -32,67 +32,59 @@
@ref="_editInputRef"
type="text" />
(@count)
+
@if (_mergeTargetTag is not null)
{
-
+
Merge into '@_mergeTargetTag'
-
+
}
else
{
-
-
-
+
}
-
-
-
+
+
}
else if (_deleteConfirmTag == tag)
{
@tag
(@count)
Remove from @count entries?
-
-
-
-
-
-
+
+
+
+
}
else
{
@tag
(@count)
- BeginRename(tag))"
- title="Rename"
- type="button">
-
-
- BeginDelete(tag))"
- title="Delete"
- type="button">
-
-
+
+
+
+
}
}
diff --git a/src/EventLogExpert.UI/FilterPane/FilterPane.razor b/src/EventLogExpert.UI/FilterPane/FilterPane.razor
index d70f012a8..14c8e51ed 100644
--- a/src/EventLogExpert.UI/FilterPane/FilterPane.razor
+++ b/src/EventLogExpert.UI/FilterPane/FilterPane.razor
@@ -25,30 +25,28 @@
-
+
Add Exclusion
-
+
@if (!IsDateFilterVisible)
{
-
+
Add Date Filter
-
+
}
-
+
Apply Filter Set
-
+
@if (OpenLogCount.Value > 0)
{
-
+ OnClick="OpenScenarioPicker">
Apply Scenario
-
+
}
@@ -64,57 +62,52 @@
}
}
@@ -318,26 +309,24 @@
-
+
Apply
-
+
-
+
Replace
-
+
}
else
{
No scenarios match the loaded logs.
}
-
+
Cancel
-
+
}
diff --git a/src/EventLogExpert.UI/FilterPane/FilterPane.razor.css b/src/EventLogExpert.UI/FilterPane/FilterPane.razor.css
index abf439612..a650c7c58 100644
--- a/src/EventLogExpert.UI/FilterPane/FilterPane.razor.css
+++ b/src/EventLogExpert.UI/FilterPane/FilterPane.razor.css
@@ -60,12 +60,12 @@
text-align: center;
}
-.icon-button:disabled {
+.filter-header-secondary ::deep .icon-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
-.icon-button:disabled:hover { filter: none; }
+.filter-header-secondary ::deep .icon-button:disabled:hover { filter: none; }
.filter-empty-state {
color: var(--text-secondary);
diff --git a/src/EventLogExpert.UI/Inputs/Button.cs b/src/EventLogExpert.UI/Inputs/Button.cs
new file mode 100644
index 000000000..8a84577f2
--- /dev/null
+++ b/src/EventLogExpert.UI/Inputs/Button.cs
@@ -0,0 +1,9 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+namespace EventLogExpert.UI.Inputs;
+
+public sealed class Button : ButtonBase
+{
+ protected override string? VariantClass => null;
+}
diff --git a/src/EventLogExpert.UI/Inputs/ButtonBase.cs b/src/EventLogExpert.UI/Inputs/ButtonBase.cs
new file mode 100644
index 000000000..a8a64948a
--- /dev/null
+++ b/src/EventLogExpert.UI/Inputs/ButtonBase.cs
@@ -0,0 +1,75 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Rendering;
+
+namespace EventLogExpert.UI.Inputs;
+
+public abstract class ButtonBase : ComponentBase
+{
+ private ElementReference _element;
+
+ [Parameter(CaptureUnmatchedValues = true)]
+ public IReadOnlyDictionary? AdditionalAttributes { get; set; }
+
+ [Parameter] public RenderFragment? ChildContent { get; set; }
+
+ [Parameter] public string CssClass { get; set; } = string.Empty;
+
+ [Parameter] public bool Disabled { get; set; }
+
+ public ElementReference Element => _element;
+
+ [Parameter] public string? IconClass { get; set; }
+
+ [Parameter] public bool IconOnly { get; set; }
+
+ [Parameter] public EventCallback OnClick { get; set; }
+
+ [Parameter] public string Type { get; set; } = "button";
+
+ protected abstract string? VariantClass { get; }
+
+ public ValueTask FocusAsync(bool preventScroll = false) => _element.FocusAsync(preventScroll);
+
+ protected override void BuildRenderTree(RenderTreeBuilder builder)
+ {
+ builder.OpenElement(0, "button");
+
+ if (AdditionalAttributes is not null)
+ {
+ builder.AddMultipleAttributes(1, AdditionalAttributes);
+ }
+
+ builder.AddAttribute(2, "type", Type);
+ builder.AddAttribute(3, "class", BuildCssClass());
+ builder.AddAttribute(4, "disabled", Disabled);
+ builder.AddAttribute(5, "onclick", OnClick);
+ builder.AddElementReferenceCapture(6, capturedRef => _element = capturedRef);
+
+ if (!string.IsNullOrWhiteSpace(IconClass))
+ {
+ builder.OpenElement(7, "i");
+ builder.AddAttribute(8, "aria-hidden", "true");
+ builder.AddAttribute(9, "class", IconClass);
+ builder.CloseElement();
+ }
+
+ builder.AddContent(10, ChildContent);
+ builder.CloseElement();
+ }
+
+ private string BuildCssClass()
+ {
+ var classes = new List(4) { "button" };
+
+ if (!string.IsNullOrWhiteSpace(VariantClass)) { classes.Add(VariantClass); }
+
+ if (IconOnly) { classes.Add("icon-button"); }
+
+ if (!string.IsNullOrWhiteSpace(CssClass)) { classes.Add(CssClass); }
+
+ return string.Join(' ', classes);
+ }
+}
diff --git a/src/EventLogExpert.UI/Inputs/DangerButton.cs b/src/EventLogExpert.UI/Inputs/DangerButton.cs
new file mode 100644
index 000000000..fd78d6883
--- /dev/null
+++ b/src/EventLogExpert.UI/Inputs/DangerButton.cs
@@ -0,0 +1,9 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+namespace EventLogExpert.UI.Inputs;
+
+public sealed class DangerButton : ButtonBase
+{
+ protected override string VariantClass => "button-red";
+}
diff --git a/src/EventLogExpert.UI/Inputs/PrimaryButton.cs b/src/EventLogExpert.UI/Inputs/PrimaryButton.cs
new file mode 100644
index 000000000..5e64505db
--- /dev/null
+++ b/src/EventLogExpert.UI/Inputs/PrimaryButton.cs
@@ -0,0 +1,9 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+namespace EventLogExpert.UI.Inputs;
+
+public sealed class PrimaryButton : ButtonBase
+{
+ protected override string VariantClass => "button-green";
+}
diff --git a/src/EventLogExpert.UI/Inputs/WarningButton.cs b/src/EventLogExpert.UI/Inputs/WarningButton.cs
new file mode 100644
index 000000000..d547339b8
--- /dev/null
+++ b/src/EventLogExpert.UI/Inputs/WarningButton.cs
@@ -0,0 +1,9 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+namespace EventLogExpert.UI.Inputs;
+
+public sealed class WarningButton : ButtonBase
+{
+ protected override string VariantClass => "button-yellow";
+}
diff --git a/src/EventLogExpert.UI/Modal/ModalChrome.razor b/src/EventLogExpert.UI/Modal/ModalChrome.razor
index f823c01ec..1e0fd9df4 100644
--- a/src/EventLogExpert.UI/Modal/ModalChrome.razor
+++ b/src/EventLogExpert.UI/Modal/ModalChrome.razor
@@ -22,13 +22,11 @@
@if (ShowCloseButton)
{
-
-
-
+
}
}
@@ -48,81 +46,68 @@
@switch (Footer)
{
case FooterPreset.CloseOnly:
-
+
@CloseLabel
-
+
+
break;
case FooterPreset.SaveCancel:
break;
case FooterPreset.ImportExportClose:
-
+
@ImportLabel
-
-
+
+
@ExportLabel
-
+
-
+
@CloseLabel
-
+
+
break;
case FooterPreset.Dismiss:
-
+
@AcceptLabel
-
+
+
break;
case FooterPreset.AcceptCancel:
-
+
@AcceptLabel
-
-
+
+
+
@CancelLabel
-
+
+
break;
case FooterPreset.None:
@@ -172,22 +157,18 @@
}
diff --git a/src/EventLogExpert.UI/Modal/ModalChrome.razor.cs b/src/EventLogExpert.UI/Modal/ModalChrome.razor.cs
index 41022459c..371f1b218 100644
--- a/src/EventLogExpert.UI/Modal/ModalChrome.razor.cs
+++ b/src/EventLogExpert.UI/Modal/ModalChrome.razor.cs
@@ -19,8 +19,8 @@ public sealed partial class ModalChrome : ComponentBase, IAsyncDisposable
private readonly string _titleId = ComponentId.NewUnique("modal-title").Value;
private ElementReference _dialogRef;
- private ElementReference _inlineAlertAcceptButtonRef;
- private ElementReference _inlineAlertCancelButtonRef;
+ private PrimaryButton? _inlineAlertAcceptButton;
+ private Button? _inlineAlertCancelButton;
private InlineAlertRequest? _inlineAlertInitializedFor;
private TextInput? _inlineAlertInput;
private string _inlineAlertPromptValue = string.Empty;
@@ -228,12 +228,12 @@ private async Task FocusInlineAlertElementAsync()
if (!string.IsNullOrEmpty(InlineAlert.AcceptLabel))
{
- await _inlineAlertAcceptButtonRef.FocusAsync(true);
+ await (_inlineAlertAcceptButton?.FocusAsync(true) ?? ValueTask.CompletedTask);
return;
}
- await _inlineAlertCancelButtonRef.FocusAsync(true);
+ await (_inlineAlertCancelButton?.FocusAsync(true) ?? ValueTask.CompletedTask);
}
catch
{
diff --git a/src/EventLogExpert.UI/Modal/ModalChrome.razor.css b/src/EventLogExpert.UI/Modal/ModalChrome.razor.css
index 6924bbe9b..0c5673b7b 100644
--- a/src/EventLogExpert.UI/Modal/ModalChrome.razor.css
+++ b/src/EventLogExpert.UI/Modal/ModalChrome.razor.css
@@ -47,7 +47,7 @@ dialog:not(:modal) {
color: var(--clr-lightblue);
}
-.dialog-close {
+::deep .dialog-close {
padding: 0 .5rem;
line-height: 1;
diff --git a/src/EventLogExpert.UI/wwwroot/app.css b/src/EventLogExpert.UI/wwwroot/app.css
index 09660239a..1ab7306e3 100644
--- a/src/EventLogExpert.UI/wwwroot/app.css
+++ b/src/EventLogExpert.UI/wwwroot/app.css
@@ -352,6 +352,21 @@ input[type="text"].input.dialog-input { padding: .25rem 0; }
.button.fixed-width { width: 11ch; }
+.button.library-saved-tab-new-button {
+ width: 100%;
+ height: var(--control-height);
+ padding: 0 .5rem;
+ text-align: left;
+ border: 1px solid var(--clr-statusbar);
+ border-radius: .25rem;
+ color: var(--text-primary);
+ background: transparent;
+}
+
+.button.library-saved-tab-new-button:hover {
+ background: color-mix(in srgb, var(--clr-statusbar) 10%, transparent);
+}
+
/* Compact icon-only buttons. Maintain WCAG 2.5.5 AA 24x24 hit target. */
.button.icon-button {
display: inline-flex;
diff --git a/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
new file mode 100644
index 000000000..4a79fe695
--- /dev/null
+++ b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
@@ -0,0 +1,206 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+using Bunit;
+using EventLogExpert.UI.Inputs;
+using Microsoft.AspNetCore.Components.Web;
+
+namespace EventLogExpert.UI.Tests.Inputs;
+
+public sealed class ButtonTests : BunitContext
+{
+ [Fact]
+ public async Task Click_InvokesOnClick()
+ {
+ bool invoked = false;
+ var component = Render(parameters => parameters
+ .Add(p => p.OnClick, () => invoked = true));
+
+ await component.Find("button").ClickAsync(new MouseEventArgs());
+
+ Assert.True(invoked);
+ }
+
+ [Fact]
+ public void Render_AdditionalAttributes_SplattedToButton()
+ {
+ var component = Render(parameters => parameters
+ .AddUnmatched("id", "apply-button")
+ .AddUnmatched("aria-label", "Apply filter")
+ .AddUnmatched("aria-pressed", "true")
+ .AddUnmatched("data-state", "active"));
+
+ var button = component.Find("button");
+ Assert.Equal("apply-button", button.GetAttribute("id"));
+ Assert.Equal("Apply filter", button.GetAttribute("aria-label"));
+ Assert.Equal("true", button.GetAttribute("aria-pressed"));
+ Assert.Equal("active", button.GetAttribute("data-state"));
+ }
+
+ [Fact]
+ public void Render_ChildContent_RenderedInsideButton()
+ {
+ var component = Render(parameters => parameters
+ .AddChildContent("Apply"));
+
+ var button = component.Find("button");
+ Assert.Contains("Apply", button.TextContent);
+ }
+
+ [Fact]
+ public void Render_CssClass_AppendedAfterBaseAndVariant()
+ {
+ var component = Render(parameters => parameters
+ .Add(p => p.CssClass, "banner-cycle-prev"));
+
+ var button = component.Find("button");
+ Assert.Contains("button", button.ClassList);
+ Assert.Contains("button-green", button.ClassList);
+ Assert.Contains("banner-cycle-prev", button.ClassList);
+ }
+
+ [Fact]
+ public void Render_DangerButton_HasButtonRedClass()
+ {
+ var component = Render();
+
+ var button = component.Find("button");
+ Assert.Contains("button", button.ClassList);
+ Assert.Contains("button-red", button.ClassList);
+ }
+
+ [Fact]
+ public void Render_Default_EmitsNativeButtonWithBaseClass()
+ {
+ var component = Render();
+
+ var button = component.Find("button");
+ Assert.Equal("button", button.GetAttribute("class"));
+ }
+
+ [Fact]
+ public void Render_Default_TypeIsButton()
+ {
+ var component = Render();
+
+ var button = component.Find("button");
+ Assert.Equal("button", button.GetAttribute("type"));
+ }
+
+ [Fact]
+ public void Render_DisabledFalse_ButtonNotDisabled()
+ {
+ var component = Render(parameters => parameters
+ .Add(p => p.Disabled, false));
+
+ var button = component.Find("button");
+ Assert.False(button.HasAttribute("disabled"));
+ }
+
+ [Fact]
+ public void Render_DisabledTrue_ButtonDisabled()
+ {
+ var component = Render(parameters => parameters
+ .Add(p => p.Disabled, true));
+
+ var button = component.Find("button");
+ Assert.True(button.HasAttribute("disabled"));
+ }
+
+ [Fact]
+ public void Render_IconAndChildContent_BothRenderedInOrder()
+ {
+ var component = Render(parameters => parameters
+ .Add(p => p.IconClass, "bi bi-check-circle")
+ .AddChildContent("Apply"));
+
+ var button = component.Find("button");
+ Assert.NotNull(button.QuerySelector("i.bi-check-circle"));
+ Assert.Contains("Apply", button.TextContent);
+ }
+
+ [Fact]
+ public void Render_IconClass_RendersIconWithAriaHidden()
+ {
+ var component = Render(parameters => parameters
+ .Add(p => p.IconClass, "bi bi-check-circle"));
+
+ var icon = component.Find("button > i");
+ Assert.Equal("bi bi-check-circle", icon.GetAttribute("class"));
+ Assert.Equal("true", icon.GetAttribute("aria-hidden"));
+ }
+
+ [Fact]
+ public void Render_IconOnly_AddsIconButtonClass()
+ {
+ var component = Render(parameters => parameters
+ .Add(p => p.IconOnly, true));
+
+ var button = component.Find("button");
+ Assert.Contains("icon-button", button.ClassList);
+ }
+
+ [Fact]
+ public void Render_NoIconClass_OmitsIconElement()
+ {
+ var component = Render(parameters => parameters
+ .AddChildContent("Apply"));
+
+ Assert.Empty(component.FindAll("button > i"));
+ }
+
+ [Fact]
+ public void Render_PrimaryButton_HasButtonGreenClass()
+ {
+ var component = Render();
+
+ var button = component.Find("button");
+ Assert.Contains("button", button.ClassList);
+ Assert.Contains("button-green", button.ClassList);
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public void Render_SplattedAutofocusBool_MatchesNativeBehavior(bool autofocus)
+ {
+ var component = Render(parameters => parameters
+ .AddUnmatched("autofocus", autofocus));
+
+ var button = component.Find("button");
+ Assert.Equal(autofocus, button.HasAttribute("autofocus"));
+ }
+
+ [Fact]
+ public void Render_SplattedClass_DoesNotOverrideComponentClass()
+ {
+ var component = Render(parameters => parameters
+ .Add(p => p.CssClass, "custom")
+ .AddUnmatched("class", "injected"));
+
+ var button = component.Find("button");
+ Assert.Contains("button", button.ClassList);
+ Assert.Contains("custom", button.ClassList);
+ Assert.DoesNotContain("injected", button.ClassList);
+ }
+
+ [Fact]
+ public void Render_TypeOverride_AppliesType()
+ {
+ var component = Render(parameters => parameters
+ .Add(p => p.Type, "submit"));
+
+ var button = component.Find("button");
+ Assert.Equal("submit", button.GetAttribute("type"));
+ }
+
+ [Fact]
+ public void Render_WarningButton_HasButtonYellowClass()
+ {
+ var component = Render();
+
+ var button = component.Find("button");
+ Assert.Contains("button", button.ClassList);
+ Assert.Contains("button-yellow", button.ClassList);
+ }
+}
From 2f6a3c9ddcf5e53b0729aef9f93ed2e8d6a0abc5 Mon Sep 17 00:00:00 2001
From: jschick04
Date: Fri, 19 Jun 2026 10:22:27 -0500
Subject: [PATCH 02/40] Route focus restoration through
ElementFocus.SafelyAsync to drop duplicated catch blocks
---
.../Database/DatabaseEntryRow.razor.cs | 9 +--
.../Tabs/ManageDatabasesTab.razor.cs | 75 ++++++-------------
src/EventLogExpert.UI/Focus/ElementFocus.cs | 8 +-
3 files changed, 28 insertions(+), 64 deletions(-)
diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs
index c3d076155..541ae09dc 100644
--- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs
+++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs
@@ -7,9 +7,9 @@
using EventLogExpert.Runtime.Database.Upgrade;
using EventLogExpert.Runtime.Menu;
using EventLogExpert.UI.Common;
+using EventLogExpert.UI.Focus;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
-using Microsoft.JSInterop;
namespace EventLogExpert.UI.Database;
@@ -110,7 +110,7 @@ UpgradeProgress is null &&
[Inject] private ITraceLogger TraceLogger { get; init; } = null!;
- public ValueTask FocusNameAsync() => _nameButtonRef.FocusAsync(preventScroll: true);
+ public ValueTask FocusNameAsync() => ElementFocus.SafelyAsync(_nameButtonRef, preventScroll: true);
protected override async Task OnAfterRenderAsync(bool firstRender)
{
@@ -118,10 +118,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
_shouldFocusNameAfterRender = false;
- try { await _nameButtonRef.FocusAsync(preventScroll: true); }
- catch (ObjectDisposedException) { }
- catch (JSDisconnectedException) { }
- catch (JSException) { }
+ await FocusNameAsync();
}
private static string PhaseVerb(UpgradePhase phase) => phase switch
diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs
index d4c23cc84..6ccf3d5b5 100644
--- a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs
+++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.cs
@@ -10,9 +10,9 @@
using EventLogExpert.Runtime.EventLog;
using EventLogExpert.UI.Common;
using EventLogExpert.UI.Database;
+using EventLogExpert.UI.Focus;
using EventLogExpert.UI.Inputs;
using Microsoft.AspNetCore.Components;
-using Microsoft.JSInterop;
using System.Collections.Immutable;
using System.Text;
@@ -127,11 +127,7 @@ public async Task ExitSelectionModeWithFocusAsync()
ExitSelectionMode();
StateHasChanged();
- try { await (_selectButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask); }
- catch (ObjectDisposedException) { }
- catch (JSDisconnectedException) { }
- catch (JSException) { }
- catch (TaskCanceledException) { }
+ await FocusRestoreAsync(_selectButton);
}
///
@@ -176,22 +172,16 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
{
_focusRestorationTarget = null;
- try
+ await (target.Target switch
{
- await (target.Target switch
- {
- FocusTarget.SameRowName when !string.IsNullOrEmpty(target.FileName) =>
- FocusEntryRowNameAsync(target.FileName),
- FocusTarget.BulkRemoveButton when HasBulkSelection =>
- _bulkRemoveButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask,
- FocusTarget.ImportButton =>
- _importButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask,
- _ => ValueTask.CompletedTask
- });
- }
- catch (ObjectDisposedException) { }
- catch (JSDisconnectedException) { }
- catch (JSException) { }
+ FocusTarget.SameRowName when !string.IsNullOrEmpty(target.FileName) =>
+ FocusEntryRowNameAsync(target.FileName),
+ FocusTarget.BulkRemoveButton when HasBulkSelection =>
+ FocusRestoreAsync(_bulkRemoveButton),
+ FocusTarget.ImportButton =>
+ FocusRestoreAsync(_importButton),
+ _ => ValueTask.CompletedTask
+ });
}
await base.OnAfterRenderAsync(firstRender);
@@ -223,6 +213,9 @@ private static string BuildBulkPlainMessage(IReadOnlyList fileNames)
return $"Are you sure you want to remove these {fileNames.Count} databases? ({list}{more})";
}
+ private static ValueTask FocusRestoreAsync(ButtonBase? button) =>
+ button != null ? ElementFocus.SafelyAsync(button.Element, preventScroll: true) : ValueTask.CompletedTask;
+
private static string GetSkipReason(DatabaseEntry entry, bool isUpgrading)
{
if (entry.BackupExists) { return "Restore from backup required"; }
@@ -557,49 +550,31 @@ private async Task FocusAfterBulkUpgradeAsync()
// button so keyboard users have a stable anchor.
if (!_isSelectionModeActive)
{
- try { await (_selectButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask); }
- catch (ObjectDisposedException) { }
- catch (JSDisconnectedException) { }
- catch (JSException) { }
- catch (TaskCanceledException) { }
+ await FocusRestoreAsync(_selectButton);
return;
}
if (_selectedForBulk.Count > 0)
{
- try { await _masterCheckboxRef.FocusAsync(preventScroll: true); }
- catch (ObjectDisposedException) { }
- catch (JSDisconnectedException) { }
- catch (JSException) { }
- catch (TaskCanceledException) { }
+ await ElementFocus.SafelyAsync(_masterCheckboxRef, preventScroll: true);
return;
}
- try { await (_selectButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask); }
- catch (ObjectDisposedException) { }
- catch (JSDisconnectedException) { }
- catch (JSException) { }
- catch (TaskCanceledException) { }
+ await FocusRestoreAsync(_selectButton);
}
private async ValueTask FocusEntryRowNameAsync(string fileName)
{
if (_rowRefs.TryGetValue(fileName, out var rowRef) && rowRef is not null)
{
- try { await rowRef.FocusNameAsync(); }
- catch (ObjectDisposedException) { }
- catch (JSDisconnectedException) { }
- catch (JSException) { }
+ await rowRef.FocusNameAsync();
return;
}
- try { await (_importButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask); }
- catch (ObjectDisposedException) { }
- catch (JSDisconnectedException) { }
- catch (JSException) { }
+ await FocusRestoreAsync(_importButton);
}
// Lookup by batch membership (active + queued) for the cancel-then-remove flow. Distinct from
@@ -783,11 +758,7 @@ private async Task OnBulkUpgradeClickAsync()
{
if (_disposed) { return; }
- try { await (_bulkUpgradeButton?.FocusAsync(preventScroll: true) ?? ValueTask.CompletedTask); }
- catch (ObjectDisposedException) { }
- catch (JSDisconnectedException) { }
- catch (JSException) { }
- catch (TaskCanceledException) { }
+ await FocusRestoreAsync(_bulkUpgradeButton);
return;
}
@@ -887,11 +858,7 @@ private async Task OnMasterCheckboxClickAsync()
SelectAll();
}
- try { await _masterCheckboxRef.FocusAsync(preventScroll: true); }
- catch (ObjectDisposedException) { }
- catch (JSDisconnectedException) { }
- catch (JSException) { }
- catch (TaskCanceledException) { }
+ await ElementFocus.SafelyAsync(_masterCheckboxRef, preventScroll: true);
}
private async Task OnSaveClickAsync()
diff --git a/src/EventLogExpert.UI/Focus/ElementFocus.cs b/src/EventLogExpert.UI/Focus/ElementFocus.cs
index 1a9caded6..a3cf07b18 100644
--- a/src/EventLogExpert.UI/Focus/ElementFocus.cs
+++ b/src/EventLogExpert.UI/Focus/ElementFocus.cs
@@ -8,11 +8,11 @@ namespace EventLogExpert.UI.Focus;
internal static class ElementFocus
{
- public static async ValueTask SafelyAsync(ElementReference target)
+ public static async ValueTask SafelyAsync(ElementReference target, bool preventScroll = false)
{
try
{
- await target.FocusAsync();
+ await target.FocusAsync(preventScroll);
}
catch (ObjectDisposedException) { }
catch (JSDisconnectedException) { }
@@ -20,11 +20,11 @@ public static async ValueTask SafelyAsync(ElementReference target)
catch (TaskCanceledException) { }
}
- public static async ValueTask TrySafelyAsync(ElementReference target)
+ public static async ValueTask TrySafelyAsync(ElementReference target, bool preventScroll = false)
{
try
{
- await target.FocusAsync();
+ await target.FocusAsync(preventScroll);
return true;
}
From 69b16fa3a0caa65c938493e2fe7d5b51646dfe38 Mon Sep 17 00:00:00 2001
From: jschick04
Date: Fri, 19 Jun 2026 11:19:00 -0500
Subject: [PATCH 03/40] Anchor scoped CSS ::deep button rules to their
component roots
---
.../Database/DatabaseEntryRow.razor.css | 6 +++---
.../Tabs/ManageDatabasesTab.razor.css | 2 +-
.../LibraryEntryFilterEditor.razor.css | 2 +-
.../FilterLibrary/LibraryEntryRow.razor.css | 18 +++++++++---------
.../Modal/ModalChrome.razor.css | 2 +-
5 files changed, 15 insertions(+), 15 deletions(-)
diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css
index 8488f7aa0..6839afa13 100644
--- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css
+++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css
@@ -91,7 +91,7 @@
gap: .5rem;
}
-::deep .db-entry-upgrade-btn {
+.db-entry-row ::deep .db-entry-upgrade-btn {
white-space: nowrap;
}
@@ -128,7 +128,7 @@
animation: db-entry-spin 1.5s linear infinite;
}
-::deep .db-entry-cancel-btn {
+.db-entry-row ::deep .db-entry-cancel-btn {
flex: 0 0 auto;
padding: .15rem .65rem;
@@ -152,7 +152,7 @@
transition: none;
}
- ::deep .db-entry-cancel-btn {
+ .db-entry-row ::deep .db-entry-cancel-btn {
animation: none;
transition: none;
}
diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.css b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.css
index a153488e4..341e8abe8 100644
--- a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.css
+++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor.css
@@ -14,7 +14,7 @@
padding: .5rem 1rem;
}
-::deep .manage-databases-import-btn {
+.manage-databases-tab ::deep .manage-databases-import-btn {
margin-left: auto;
}
diff --git a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryFilterEditor.razor.css b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryFilterEditor.razor.css
index de6eb0d66..3366fd625 100644
--- a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryFilterEditor.razor.css
+++ b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryFilterEditor.razor.css
@@ -16,7 +16,7 @@
font-style: italic;
}
-::deep .library-entry-filter-editor-add-button {
+.library-entry-filter-editor ::deep .library-entry-filter-editor-add-button {
align-self: flex-start;
margin-top: .25rem;
}
diff --git a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.css b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.css
index 36611ddc1..723d3fda9 100644
--- a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.css
+++ b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.css
@@ -22,7 +22,7 @@
font-size: 1rem;
}
-::deep .library-entry-expand-toggle {
+.library-entry ::deep .library-entry-expand-toggle {
flex-shrink: 0;
}
@@ -137,7 +137,7 @@
outline-offset: 1px;
}
-::deep .library-entry-tag-chip-more {
+.library-entry ::deep .library-entry-tag-chip-more {
flex: 0 0 auto;
padding: 0 .375rem;
font-size: .75rem;
@@ -147,12 +147,12 @@
border-radius: .2rem;
}
-::deep .library-entry-tag-chip-more:hover {
+.library-entry ::deep .library-entry-tag-chip-more:hover {
color: var(--text-primary);
background: color-mix(in srgb, var(--text-primary) 8%, transparent);
}
-::deep .library-entry-tag-add-inline {
+.library-entry ::deep .library-entry-tag-add-inline {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -169,23 +169,23 @@
transition: background 120ms ease, color 120ms ease;
}
-::deep .library-entry-tag-add-inline > .bi {
+.library-entry ::deep .library-entry-tag-add-inline > .bi {
font-size: 14px;
line-height: 1;
}
-::deep .library-entry-tag-add-inline:hover,
-::deep .library-entry-tag-add-inline:focus-visible {
+.library-entry ::deep .library-entry-tag-add-inline:hover,
+.library-entry ::deep .library-entry-tag-add-inline:focus-visible {
color: var(--clr-statusbar);
background: color-mix(in srgb, var(--clr-statusbar) 10%, transparent);
}
-::deep .library-entry-tag-add-inline:focus-visible {
+.library-entry ::deep .library-entry-tag-add-inline:focus-visible {
outline: 2px solid var(--clr-statusbar);
outline-offset: 1px;
}
-::deep .library-entry-tags-done {
+.library-entry ::deep .library-entry-tags-done {
padding: 0 .25rem;
flex-shrink: 0;
}
diff --git a/src/EventLogExpert.UI/Modal/ModalChrome.razor.css b/src/EventLogExpert.UI/Modal/ModalChrome.razor.css
index 0c5673b7b..4a2724908 100644
--- a/src/EventLogExpert.UI/Modal/ModalChrome.razor.css
+++ b/src/EventLogExpert.UI/Modal/ModalChrome.razor.css
@@ -47,7 +47,7 @@ dialog:not(:modal) {
color: var(--clr-lightblue);
}
-::deep .dialog-close {
+.dialog-header ::deep .dialog-close {
padding: 0 .5rem;
line-height: 1;
From 3e2f13e6888970aeb0f973211d91fe91ce22cef7 Mon Sep 17 00:00:00 2001
From: jschick04
Date: Fri, 19 Jun 2026 11:54:15 -0500
Subject: [PATCH 04/40] Separate the Button icon from its label with a space
when both are present
---
src/EventLogExpert.UI/Inputs/ButtonBase.cs | 7 +++++-
.../Inputs/ButtonTests.cs | 22 +++++++++++++++++++
2 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/src/EventLogExpert.UI/Inputs/ButtonBase.cs b/src/EventLogExpert.UI/Inputs/ButtonBase.cs
index a8a64948a..b10ecf388 100644
--- a/src/EventLogExpert.UI/Inputs/ButtonBase.cs
+++ b/src/EventLogExpert.UI/Inputs/ButtonBase.cs
@@ -54,9 +54,14 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.AddAttribute(8, "aria-hidden", "true");
builder.AddAttribute(9, "class", IconClass);
builder.CloseElement();
+
+ if (ChildContent is not null)
+ {
+ builder.AddContent(10, " ");
+ }
}
- builder.AddContent(10, ChildContent);
+ builder.AddContent(11, ChildContent);
builder.CloseElement();
}
diff --git a/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
index 4a79fe695..90b68837a 100644
--- a/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
+++ b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
@@ -119,6 +119,17 @@ public void Render_IconAndChildContent_BothRenderedInOrder()
Assert.Contains("Apply", button.TextContent);
}
+ [Fact]
+ public void Render_IconAndChildContent_SeparatedBySpace()
+ {
+ var component = Render(parameters => parameters
+ .Add(p => p.IconClass, "bi bi-check-circle")
+ .AddChildContent("Apply"));
+
+ var button = component.Find("button");
+ Assert.Contains(" Apply", button.InnerHtml);
+ }
+
[Fact]
public void Render_IconClass_RendersIconWithAriaHidden()
{
@@ -140,6 +151,17 @@ public void Render_IconOnly_AddsIconButtonClass()
Assert.Contains("icon-button", button.ClassList);
}
+ [Fact]
+ public void Render_IconOnly_NoTrailingSpaceAfterIcon()
+ {
+ var component = Render(parameters => parameters
+ .Add(p => p.IconClass, "bi bi-x")
+ .Add(p => p.IconOnly, true));
+
+ var button = component.Find("button");
+ Assert.DoesNotContain(" ", button.InnerHtml);
+ }
+
[Fact]
public void Render_NoIconClass_OmitsIconElement()
{
From fec5e474e054688e8edd088c6c1608ff7b9459a2 Mon Sep 17 00:00:00 2001
From: jschick04
Date: Fri, 19 Jun 2026 13:12:05 -0500
Subject: [PATCH 05/40] Expose MouseEventArgs on the Button OnClick callback
---
src/EventLogExpert.UI/Inputs/ButtonBase.cs | 3 ++-
.../EventLogExpert.UI.Tests/Inputs/ButtonTests.cs | 14 ++++++++++++++
2 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/src/EventLogExpert.UI/Inputs/ButtonBase.cs b/src/EventLogExpert.UI/Inputs/ButtonBase.cs
index b10ecf388..ee5950210 100644
--- a/src/EventLogExpert.UI/Inputs/ButtonBase.cs
+++ b/src/EventLogExpert.UI/Inputs/ButtonBase.cs
@@ -3,6 +3,7 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;
+using Microsoft.AspNetCore.Components.Web;
namespace EventLogExpert.UI.Inputs;
@@ -25,7 +26,7 @@ public abstract class ButtonBase : ComponentBase
[Parameter] public bool IconOnly { get; set; }
- [Parameter] public EventCallback OnClick { get; set; }
+ [Parameter] public EventCallback OnClick { get; set; }
[Parameter] public string Type { get; set; } = "button";
diff --git a/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
index 90b68837a..ff9a885af 100644
--- a/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
+++ b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
@@ -21,6 +21,20 @@ public async Task Click_InvokesOnClick()
Assert.True(invoked);
}
+ [Fact]
+ public async Task Click_InvokesOnClickWithMouseEventArgs()
+ {
+ MouseEventArgs? received = null;
+ var component = Render(parameters => parameters
+ .Add(p => p.OnClick, e => received = e));
+
+ await component.Find("button").ClickAsync(new MouseEventArgs { Button = 1, CtrlKey = true });
+
+ Assert.NotNull(received);
+ Assert.Equal(1, received!.Button);
+ Assert.True(received.CtrlKey);
+ }
+
[Fact]
public void Render_AdditionalAttributes_SplattedToButton()
{
From 127dc8dbad315c199853610acab51a39b6264cd8 Mon Sep 17 00:00:00 2001
From: jschick04
Date: Fri, 19 Jun 2026 14:26:28 -0500
Subject: [PATCH 06/40] Normalize a blank Button Type to "button" to prevent
accidental form submits
---
src/EventLogExpert.UI/Inputs/ButtonBase.cs | 2 +-
.../EventLogExpert.UI.Tests/Inputs/ButtonTests.cs | 13 +++++++++++++
2 files changed, 14 insertions(+), 1 deletion(-)
diff --git a/src/EventLogExpert.UI/Inputs/ButtonBase.cs b/src/EventLogExpert.UI/Inputs/ButtonBase.cs
index ee5950210..69bc39281 100644
--- a/src/EventLogExpert.UI/Inputs/ButtonBase.cs
+++ b/src/EventLogExpert.UI/Inputs/ButtonBase.cs
@@ -43,7 +43,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.AddMultipleAttributes(1, AdditionalAttributes);
}
- builder.AddAttribute(2, "type", Type);
+ builder.AddAttribute(2, "type", string.IsNullOrWhiteSpace(Type) ? "button" : Type);
builder.AddAttribute(3, "class", BuildCssClass());
builder.AddAttribute(4, "disabled", Disabled);
builder.AddAttribute(5, "onclick", OnClick);
diff --git a/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
index ff9a885af..3ef7649b5 100644
--- a/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
+++ b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
@@ -51,6 +51,19 @@ public void Render_AdditionalAttributes_SplattedToButton()
Assert.Equal("active", button.GetAttribute("data-state"));
}
+ [Theory]
+ [InlineData("")]
+ [InlineData(" ")]
+ [InlineData(null)]
+ public void Render_BlankType_DefaultsToButton(string? type)
+ {
+ var component = Render(parameters => parameters
+ .Add(p => p.Type, type!));
+
+ var button = component.Find("button");
+ Assert.Equal("button", button.GetAttribute("type"));
+ }
+
[Fact]
public void Render_ChildContent_RenderedInsideButton()
{
From f71e816b478db2bfed6a026f8d22b21942c86d53 Mon Sep 17 00:00:00 2001
From: jschick04
Date: Fri, 19 Jun 2026 15:18:40 -0500
Subject: [PATCH 07/40] Assert Button icon ordering and spacing via DOM nodes
instead of InnerHtml
---
tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
index 3ef7649b5..00ba87684 100644
--- a/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
+++ b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
@@ -154,7 +154,8 @@ public void Render_IconAndChildContent_SeparatedBySpace()
.AddChildContent("Apply"));
var button = component.Find("button");
- Assert.Contains(" Apply", button.InnerHtml);
+ Assert.Equal("I", button.FirstChild?.NodeName);
+ Assert.Equal(" Apply", button.TextContent);
}
[Fact]
@@ -186,7 +187,7 @@ public void Render_IconOnly_NoTrailingSpaceAfterIcon()
.Add(p => p.IconOnly, true));
var button = component.Find("button");
- Assert.DoesNotContain(" ", button.InnerHtml);
+ Assert.Empty(button.TextContent);
}
[Fact]
From f7f580ac5c5d071156b46b8b7f395bffe8758881 Mon Sep 17 00:00:00 2001
From: jschick04
Date: Fri, 19 Jun 2026 15:58:34 -0500
Subject: [PATCH 08/40] Route CriticalBanner reload-button focus through
ElementFocus.SafelyAsync
---
src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs | 11 +++--------
1 file changed, 3 insertions(+), 8 deletions(-)
diff --git a/src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs b/src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs
index 1e676a8c9..86514d79f 100644
--- a/src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs
+++ b/src/EventLogExpert.UI/Banner/CriticalBanner.razor.cs
@@ -5,9 +5,9 @@
using EventLogExpert.Runtime.Banner;
using EventLogExpert.Runtime.Common.Clipboard;
using EventLogExpert.Runtime.Common.Restart;
+using EventLogExpert.UI.Focus;
using EventLogExpert.UI.Inputs;
using Microsoft.AspNetCore.Components;
-using Microsoft.JSInterop;
namespace EventLogExpert.UI.Banner;
@@ -39,14 +39,9 @@ public void Dispose()
protected override async Task OnAfterRenderAsync(bool firstRender)
{
- if (firstRender)
+ if (firstRender && _reloadButton is { } reloadButton)
{
- try
- {
- await (_reloadButton?.FocusAsync() ?? ValueTask.CompletedTask);
- }
- catch (JSDisconnectedException) { /* Circuit gone — nothing to focus. */ }
- catch (TaskCanceledException) { /* Focus cancelled mid-render; harmless. */ }
+ await ElementFocus.SafelyAsync(reloadButton.Element);
}
}
From 18f7fc7ee1945a54ed7559fe4b442f61bc7459df Mon Sep 17 00:00:00 2001
From: jschick04
Date: Fri, 19 Jun 2026 19:10:18 -0500
Subject: [PATCH 09/40] Standardize icon buttons on IconClass and fix
rotating-caret centering
---
.../Banner/BannerCycleStateService.cs | 8 +-
.../Banner/CriticalBanner.razor | 12 +-
.../Database/DatabaseEntryRow.razor | 12 +-
.../Database/DatabaseEntryRow.razor.cs | 2 +-
.../Database/DatabaseRecoveryModal.razor.cs | 6 +-
.../DatabaseTools/DatabaseToolsLogView.razor | 12 +-
.../DatabaseTools/DatabaseToolsModal.razor.cs | 2 +-
.../Tabs/CreateDatabaseTab.razor | 16 +--
.../DatabaseTools/Tabs/DiffDatabasesTab.razor | 12 +-
.../Tabs/ManageDatabasesTab.razor | 5 +-
.../Tabs/ManageDatabasesTab.razor.cs | 2 +-
.../DatabaseTools/Tabs/MergeDatabaseTab.razor | 16 +--
.../DatabaseTools/Tabs/ShowProvidersTab.razor | 14 +-
.../Tabs/UpgradeDatabaseTab.razor | 12 +-
.../Editing/FilterEditPanel.razor | 4 +-
.../FilterEditor/FilterEditorCore.razor.cs | 4 +-
.../LibraryEntryFilterEditor.razor | 4 +-
.../FilterLibrary/LibraryEntryRow.razor | 3 +-
.../FilterLibrary/LibrarySavedTabHeader.razor | 4 +-
.../FilterLibrary/TagManagementPanel.razor | 3 +-
.../FilterPane/FilterPane.razor | 123 +++++++++---------
.../FilterPane/FilterPane.razor.cs | 26 ++--
src/EventLogExpert.UI/Inputs/ButtonBase.cs | 2 +-
.../LogTable/LogTablePane.razor.css | 2 -
src/EventLogExpert.UI/Modal/ModalBase.cs | 8 +-
src/EventLogExpert.UI/wwwroot/app.css | 23 +++-
.../Inputs/ButtonTests.cs | 2 +-
27 files changed, 166 insertions(+), 173 deletions(-)
diff --git a/src/EventLogExpert.UI/Banner/BannerCycleStateService.cs b/src/EventLogExpert.UI/Banner/BannerCycleStateService.cs
index 0faac79d1..4e6b5d77d 100644
--- a/src/EventLogExpert.UI/Banner/BannerCycleStateService.cs
+++ b/src/EventLogExpert.UI/Banner/BannerCycleStateService.cs
@@ -217,7 +217,7 @@ private static bool ItemMatches(BannerCycleItem selected, BannerCycleItem candid
return selected.EntryId == candidate.EntryId;
}
- // BannerView enum is ordered for display, not priority — use this rank for comparisons.
+ // BannerView enum is ordered for display, not priority - use this rank for comparisons.
private static int PriorityRank(BannerView view) => view switch
{
BannerView.Critical => 6,
@@ -257,7 +257,7 @@ private void RebuildAndReselectLocked()
bool attentionSuppressed =
_modalCoordinator.ActiveSession?.ComponentType == typeof(DatabaseToolsModal);
- // Capture each facet snapshot once — the same data drives BuildCycle AND the fingerprint.
+ // Capture each facet snapshot once - the same data drives BuildCycle AND the fingerprint.
var critical = _critical.CurrentCritical;
var errors = _errors.ErrorBanners;
var attentionEntries = _attention.AttentionEntries;
@@ -276,7 +276,7 @@ private void RebuildAndReselectLocked()
exportProgress,
infos);
- // Fingerprint is built from the UNFILTERED source — otherwise modal close would re-introduce
+ // Fingerprint is built from the UNFILTERED source - otherwise modal close would re-introduce
// Attention and wrongly trigger priorityOverride on every reentry.
var currentSourceFingerprint = ComputeSourceFingerprintFromSnapshot(
critical,
@@ -329,7 +329,7 @@ private void RebuildAndReselectLocked()
// Gate user-preferred restore against a still-active priority-stolen item only. This does NOT
// block restore when a higher-priority item that COEXISTED with the user's preference is
- // present — only blocks against a newly-arrived steal that is still in the cycle.
+ // present - only blocks against a newly-arrived steal that is still in the cycle.
if (_priorityStolenSelection is null
&& TryApplySelection(_userPreferredItem, items))
{
diff --git a/src/EventLogExpert.UI/Banner/CriticalBanner.razor b/src/EventLogExpert.UI/Banner/CriticalBanner.razor
index 337de69a8..ae453e16c 100644
--- a/src/EventLogExpert.UI/Banner/CriticalBanner.razor
+++ b/src/EventLogExpert.UI/Banner/CriticalBanner.razor
@@ -2,14 +2,14 @@
An unexpected error occurred: @Critical.GetType().Name: @Critical.Message
-
- Reload
+
+ Reload
-
- Relaunch
+
+ Relaunch
-
- Copy details
+
+ Copy details
diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor
index abae970b4..7aa096c23 100644
--- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor
+++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor
@@ -76,11 +76,12 @@
- Upgrade
+ Upgrade
break;
@@ -89,11 +90,12 @@
- Retry Upgrade
+ Retry Upgrade
break;
@@ -102,11 +104,12 @@
- Restore
+ Restore
break;
@@ -114,8 +117,9 @@
case ActionKind.RetryClassification:
- Retry classification
+ Retry classification
break;
diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs
index 541ae09dc..3527f791b 100644
--- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs
+++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs
@@ -131,7 +131,7 @@ protected override async Task OnAfterRenderAsync(bool firstRender)
private void HandleContextMenu(MouseEventArgs args)
{
- // Suppressed in selection mode — bulk strip is the action surface.
+ // Suppressed in selection mode - bulk strip is the action surface.
if (IsSelectionModeActive) { return; }
var items = new List
public sealed partial class FilterPredicateEditor : ComponentBase
{
- private ElementReference _chipEditButtonRef;
- private ElementReference _editorFirstInputRef;
+ private ChromelessButton? _chipEditButton;
+ private ChromelessButton? _editorFirstInput;
[Parameter] public bool IsEditing { get; set; }
@@ -61,9 +62,11 @@ private string SummaryText
}
}
- internal ValueTask FocusChipEditButtonAsync() => ElementFocus.SafelyAsync(_chipEditButtonRef);
+ internal ValueTask FocusChipEditButtonAsync() =>
+ _chipEditButton is { } button ? ElementFocus.SafelyAsync(button.Element) : ValueTask.CompletedTask;
- internal ValueTask FocusEditorFirstInputAsync() => ElementFocus.SafelyAsync(_editorFirstInputRef);
+ internal ValueTask FocusEditorFirstInputAsync() =>
+ _editorFirstInput is { } button ? ElementFocus.SafelyAsync(button.Element) : ValueTask.CompletedTask;
///
/// AND/OR joiner click handler. Toggles and bubbles the change up
diff --git a/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateEditor.razor.css b/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateEditor.razor.css
index e3eb131ff..a1927aca5 100644
--- a/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateEditor.razor.css
+++ b/src/EventLogExpert.UI/FilterEditor/Editing/FilterPredicateEditor.razor.css
@@ -7,13 +7,13 @@
min-height: 24px;
}
-.filter-predicate:hover .filter-predicate-edit,
-.filter-predicate:hover .filter-predicate-remove {
+.filter-predicate:hover ::deep .filter-predicate-edit,
+.filter-predicate:hover ::deep .filter-predicate-remove {
border-color: var(--clr-lightblue);
}
-.filter-predicate-edit,
-.filter-predicate-remove {
+.filter-predicate ::deep .filter-predicate-edit,
+.filter-predicate ::deep .filter-predicate-remove {
display: inline-flex;
align-items: center;
@@ -29,7 +29,7 @@
transition: color 0.1s ease-in-out, background-color 0.1s ease-in-out, border-color 0.1s ease-in-out;
}
-.filter-predicate-edit {
+.filter-predicate ::deep .filter-predicate-edit {
gap: 0.25rem;
padding: 2px 6px 2px 12px;
border-right: none;
@@ -56,7 +56,7 @@
text-overflow: ellipsis;
}
-.filter-predicate-remove {
+.filter-predicate ::deep .filter-predicate-remove {
min-width: 32px;
padding: 2px 12px 2px 8px;
@@ -67,19 +67,19 @@
border-bottom-right-radius: 999px;
}
-.filter-predicate-remove .bi {
+.filter-predicate ::deep .filter-predicate-remove .bi {
font-size: 0.9rem;
line-height: 1;
}
-.filter-predicate-remove:hover {
+.filter-predicate ::deep .filter-predicate-remove:hover {
color: var(--clr-on-status-fill);
background: var(--clr-red);
border-color: var(--clr-red) !important;
}
-.filter-predicate-edit:focus-visible,
-.filter-predicate-remove:focus-visible {
+.filter-predicate ::deep .filter-predicate-edit:focus-visible,
+.filter-predicate ::deep .filter-predicate-remove:focus-visible {
outline: 2px solid var(--toggle-accent);
outline-offset: -2px;
}
@@ -88,10 +88,10 @@
.filter-predicate {
border: 1px solid ButtonText;
}
- .filter-predicate-remove {
+ .filter-predicate ::deep .filter-predicate-remove {
border-left: 1px solid ButtonText;
}
- .filter-predicate-remove:hover {
+ .filter-predicate ::deep .filter-predicate-remove:hover {
background: Highlight;
color: HighlightText;
}
@@ -112,7 +112,7 @@
}
/* AND/OR toggle in editing mode \u2014 neutral pill matching the chip aesthetic. */
-.filter-predicate-and-or {
+.filter-predicate-editing ::deep .filter-predicate-and-or {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -136,23 +136,23 @@
transition: color 0.1s ease-in-out, border-color 0.1s ease-in-out;
}
-.filter-predicate-and-or[data-mode="or"] {
+.filter-predicate-editing ::deep .filter-predicate-and-or[data-mode="or"] {
color: var(--clr-lightblue);
border-color: var(--clr-lightblue);
}
-.filter-predicate-and-or:hover {
+.filter-predicate-editing ::deep .filter-predicate-and-or:hover {
color: var(--clr-lightblue);
border-color: var(--clr-lightblue);
}
-.filter-predicate-and-or:focus-visible {
+.filter-predicate-editing ::deep .filter-predicate-and-or:focus-visible {
outline: 2px solid var(--toggle-accent);
outline-offset: 2px;
}
@media (forced-colors: active) {
- .filter-predicate-and-or[data-mode="or"] {
+ .filter-predicate-editing ::deep .filter-predicate-and-or[data-mode="or"] {
color: Highlight;
border-color: Highlight;
}
diff --git a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor
index b3d157431..c8f5abc64 100644
--- a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor
+++ b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor
@@ -49,13 +49,11 @@
var tag = Entry.Tags[i];
@tag
- OnRemoveTagAsync(tag))"
- @onclick:stopPropagation="true"
- type="button">
-
-
+
}
diff --git a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.css b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.css
index 723d3fda9..0f8a5aba7 100644
--- a/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.css
+++ b/src/EventLogExpert.UI/FilterLibrary/LibraryEntryRow.razor.css
@@ -100,7 +100,7 @@
margin-right: .125rem;
}
-.library-entry-tag-chip-remove {
+.library-entry-tag-chip ::deep .library-entry-tag-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -118,20 +118,20 @@
transition: width 120ms ease, opacity 120ms ease, background 120ms ease, color 120ms ease;
}
-.library-entry-tag-chip:hover .library-entry-tag-chip-remove,
-.library-entry-tag-chip:focus-within .library-entry-tag-chip-remove {
+.library-entry-tag-chip:hover ::deep .library-entry-tag-chip-remove,
+.library-entry-tag-chip:focus-within ::deep .library-entry-tag-chip-remove {
width: 16px;
opacity: 0.6;
margin-left: 2px;
}
-.library-entry-tag-chip-remove:hover {
+.library-entry-tag-chip ::deep .library-entry-tag-chip-remove:hover {
opacity: 1;
color: var(--clr-red);
background: color-mix(in srgb, var(--clr-red) 14%, transparent);
}
-.library-entry-tag-chip-remove:focus-visible {
+.library-entry-tag-chip ::deep .library-entry-tag-chip-remove:focus-visible {
opacity: 1;
outline: 2px solid var(--clr-statusbar);
outline-offset: 1px;
diff --git a/src/EventLogExpert.UI/Inputs/Button.cs b/src/EventLogExpert.UI/Inputs/Button.cs
index 8a84577f2..49863d9b3 100644
--- a/src/EventLogExpert.UI/Inputs/Button.cs
+++ b/src/EventLogExpert.UI/Inputs/Button.cs
@@ -3,7 +3,7 @@
namespace EventLogExpert.UI.Inputs;
-public sealed class Button : ButtonBase
+public sealed class Button : StyledButtonBase
{
protected override string? VariantClass => null;
}
diff --git a/src/EventLogExpert.UI/Inputs/ButtonBase.cs b/src/EventLogExpert.UI/Inputs/ButtonBase.cs
index ef2156e2e..e2bbba62c 100644
--- a/src/EventLogExpert.UI/Inputs/ButtonBase.cs
+++ b/src/EventLogExpert.UI/Inputs/ButtonBase.cs
@@ -24,16 +24,16 @@ public abstract class ButtonBase : ComponentBase
[Parameter] public string? IconClass { get; set; }
- [Parameter] public bool IconOnly { get; set; }
-
[Parameter] public EventCallback OnClick { get; set; }
- [Parameter] public string? Type { get; set; } = "button";
+ [Parameter] public bool StopPropagation { get; set; }
- protected abstract string? VariantClass { get; }
+ [Parameter] public string? Type { get; set; } = "button";
public ValueTask FocusAsync(bool preventScroll = false) => _element.FocusAsync(preventScroll);
+ protected abstract string BuildCssClass();
+
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "button");
@@ -44,38 +44,40 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
}
builder.AddAttribute(2, "type", string.IsNullOrWhiteSpace(Type) ? "button" : Type);
- builder.AddAttribute(3, "class", BuildCssClass());
+
+ var cssClass = BuildCssClass();
+
+ if (!string.IsNullOrWhiteSpace(cssClass))
+ {
+ builder.AddAttribute(3, "class", cssClass);
+ }
+
builder.AddAttribute(4, "disabled", Disabled);
- builder.AddAttribute(5, "onclick", OnClick);
- builder.AddElementReferenceCapture(6, capturedRef => _element = capturedRef);
+ builder.AddAttribute(5, "onclick", EventCallback.Factory.Create(this, HandleClickAsync));
+
+ if (StopPropagation)
+ {
+ builder.AddEventStopPropagationAttribute(6, "onclick", true);
+ }
+
+ builder.AddElementReferenceCapture(7, capturedRef => _element = capturedRef);
if (!string.IsNullOrWhiteSpace(IconClass))
{
- builder.OpenElement(7, "i");
- builder.AddAttribute(8, "aria-hidden", "true");
- builder.AddAttribute(9, "class", IconClass);
+ builder.OpenElement(8, "i");
+ builder.AddAttribute(9, "aria-hidden", "true");
+ builder.AddAttribute(10, "class", IconClass);
builder.CloseElement();
if (ChildContent is not null)
{
- builder.AddContent(10, " ");
+ builder.AddContent(11, " ");
}
}
- builder.AddContent(11, ChildContent);
+ builder.AddContent(12, ChildContent);
builder.CloseElement();
}
- private string BuildCssClass()
- {
- var classes = new List(4) { "button" };
-
- if (!string.IsNullOrWhiteSpace(VariantClass)) { classes.Add(VariantClass); }
-
- if (IconOnly) { classes.Add("icon-button"); }
-
- if (!string.IsNullOrWhiteSpace(CssClass)) { classes.Add(CssClass); }
-
- return string.Join(' ', classes);
- }
+ private Task HandleClickAsync(MouseEventArgs args) => Disabled ? Task.CompletedTask : OnClick.InvokeAsync(args);
}
diff --git a/src/EventLogExpert.UI/Inputs/ChromelessButton.cs b/src/EventLogExpert.UI/Inputs/ChromelessButton.cs
new file mode 100644
index 000000000..7c4797fb8
--- /dev/null
+++ b/src/EventLogExpert.UI/Inputs/ChromelessButton.cs
@@ -0,0 +1,9 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+namespace EventLogExpert.UI.Inputs;
+
+public sealed class ChromelessButton : ButtonBase
+{
+ protected override string BuildCssClass() => CssClass;
+}
diff --git a/src/EventLogExpert.UI/Inputs/DangerButton.cs b/src/EventLogExpert.UI/Inputs/DangerButton.cs
index fd78d6883..b52a33270 100644
--- a/src/EventLogExpert.UI/Inputs/DangerButton.cs
+++ b/src/EventLogExpert.UI/Inputs/DangerButton.cs
@@ -3,7 +3,7 @@
namespace EventLogExpert.UI.Inputs;
-public sealed class DangerButton : ButtonBase
+public sealed class DangerButton : StyledButtonBase
{
protected override string VariantClass => "button-red";
}
diff --git a/src/EventLogExpert.UI/Inputs/PrimaryButton.cs b/src/EventLogExpert.UI/Inputs/PrimaryButton.cs
index 5e64505db..c0a476d01 100644
--- a/src/EventLogExpert.UI/Inputs/PrimaryButton.cs
+++ b/src/EventLogExpert.UI/Inputs/PrimaryButton.cs
@@ -3,7 +3,7 @@
namespace EventLogExpert.UI.Inputs;
-public sealed class PrimaryButton : ButtonBase
+public sealed class PrimaryButton : StyledButtonBase
{
protected override string VariantClass => "button-green";
}
diff --git a/src/EventLogExpert.UI/Inputs/StyledButtonBase.cs b/src/EventLogExpert.UI/Inputs/StyledButtonBase.cs
new file mode 100644
index 000000000..42c57e9de
--- /dev/null
+++ b/src/EventLogExpert.UI/Inputs/StyledButtonBase.cs
@@ -0,0 +1,26 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+using Microsoft.AspNetCore.Components;
+
+namespace EventLogExpert.UI.Inputs;
+
+public abstract class StyledButtonBase : ButtonBase
+{
+ [Parameter] public bool IconOnly { get; set; }
+
+ protected abstract string? VariantClass { get; }
+
+ protected override string BuildCssClass()
+ {
+ var classes = new List(4) { "button" };
+
+ if (!string.IsNullOrWhiteSpace(VariantClass)) { classes.Add(VariantClass); }
+
+ if (IconOnly) { classes.Add("icon-button"); }
+
+ if (!string.IsNullOrWhiteSpace(CssClass)) { classes.Add(CssClass); }
+
+ return string.Join(' ', classes);
+ }
+}
diff --git a/src/EventLogExpert.UI/Inputs/TagPicker.razor b/src/EventLogExpert.UI/Inputs/TagPicker.razor
index 7d96e7ed7..746812ae4 100644
--- a/src/EventLogExpert.UI/Inputs/TagPicker.razor
+++ b/src/EventLogExpert.UI/Inputs/TagPicker.razor
@@ -7,12 +7,10 @@
@tag
- RemoveTagAsync(index)"
- type="button">
-
-
+
}
diff --git a/src/EventLogExpert.UI/Inputs/TagPicker.razor.css b/src/EventLogExpert.UI/Inputs/TagPicker.razor.css
index 049919f37..7d8d7a36f 100644
--- a/src/EventLogExpert.UI/Inputs/TagPicker.razor.css
+++ b/src/EventLogExpert.UI/Inputs/TagPicker.razor.css
@@ -36,7 +36,7 @@
white-space: nowrap;
}
-.tag-picker-chip-remove {
+.tag-picker-chip ::deep .tag-picker-chip-remove {
display: inline-flex;
align-items: center;
justify-content: center;
@@ -50,7 +50,7 @@
line-height: 1;
}
-.tag-picker-chip-remove:hover {
+.tag-picker-chip ::deep .tag-picker-chip-remove:hover {
color: var(--clr-red);
}
diff --git a/src/EventLogExpert.UI/Inputs/WarningButton.cs b/src/EventLogExpert.UI/Inputs/WarningButton.cs
index d547339b8..e09626e5a 100644
--- a/src/EventLogExpert.UI/Inputs/WarningButton.cs
+++ b/src/EventLogExpert.UI/Inputs/WarningButton.cs
@@ -3,7 +3,7 @@
namespace EventLogExpert.UI.Inputs;
-public sealed class WarningButton : ButtonBase
+public sealed class WarningButton : StyledButtonBase
{
protected override string VariantClass => "button-yellow";
}
diff --git a/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
index 97e58c6bb..7b113c439 100644
--- a/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
+++ b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ButtonTests.cs
@@ -35,6 +35,19 @@ public async Task Click_InvokesOnClickWithMouseEventArgs()
Assert.True(received.CtrlKey);
}
+ [Fact]
+ public async Task Click_WhenDisabled_DoesNotInvokeOnClick()
+ {
+ bool invoked = false;
+ var component = Render(parameters => parameters
+ .Add(p => p.Disabled, true)
+ .Add(p => p.OnClick, () => invoked = true));
+
+ await component.Find("button").ClickAsync(new MouseEventArgs());
+
+ Assert.False(invoked);
+ }
+
[Fact]
public void Render_AdditionalAttributes_SplattedToButton()
{
diff --git a/tests/Unit/EventLogExpert.UI.Tests/Inputs/ChromelessButtonTests.cs b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ChromelessButtonTests.cs
new file mode 100644
index 000000000..d83e629f8
--- /dev/null
+++ b/tests/Unit/EventLogExpert.UI.Tests/Inputs/ChromelessButtonTests.cs
@@ -0,0 +1,30 @@
+// // Copyright (c) Microsoft Corporation.
+// // Licensed under the MIT License.
+
+using Bunit;
+using EventLogExpert.UI.Inputs;
+
+namespace EventLogExpert.UI.Tests.Inputs;
+
+public sealed class ChromelessButtonTests : BunitContext
+{
+ [Fact]
+ public void Render_EmptyCssClass_OmitsClassAttribute()
+ {
+ var component = Render();
+
+ var button = component.Find("button");
+ Assert.False(button.HasAttribute("class"));
+ }
+
+ [Fact]
+ public void Render_WithCssClass_RendersCssClassWithoutButtonChrome()
+ {
+ var component = Render(parameters => parameters
+ .Add(p => p.CssClass, "empty-dashboard__launch"));
+
+ var button = component.Find("button");
+ Assert.Contains("empty-dashboard__launch", button.ClassList);
+ Assert.DoesNotContain("button", button.ClassList);
+ }
+}
From 8371b4d1793540e255037d7da43ab019904e9fd4 Mon Sep 17 00:00:00 2001
From: jschick04
Date: Sat, 20 Jun 2026 12:32:09 -0500
Subject: [PATCH 13/40] Extend ButtonBase with keyboard events and migrate
remaining buttons to ChromelessButton
---
.../Dashboard/ScenarioDetail.razor | 10 ++---
.../Dashboard/ScenarioDetail.razor.css | 8 ++--
.../Database/DatabaseEntryRow.razor | 2 +-
.../Database/DatabaseEntryRow.razor.cs | 8 +++-
.../Database/DatabaseEntryRow.razor.css | 2 +-
.../Tabs/ManageDatabasesTab.razor | 16 ++++---
.../Tabs/ManageDatabasesTab.razor.cs | 6 +--
.../Tabs/ManageDatabasesTab.razor.css | 4 +-
.../DetailsPane/DetailsPane.razor | 28 +++++--------
.../DetailsPane/DetailsPane.razor.cs | 42 -------------------
.../DetailsPane/DetailsPane.razor.css | 22 ++++++++--
.../FilterLibrary/FilterLibraryModal.razor | 31 +++++++-------
.../FilterLibraryModal.razor.css | 12 +++---
src/EventLogExpert.UI/Inputs/ButtonBase.cs | 31 +++++++++++---
.../LogTable/LogTablePane.razor | 9 ++--
.../LogTable/LogTablePane.razor.css | 10 ++---
src/EventLogExpert.UI/Menu/MenuBar.razor | 17 ++++----
src/EventLogExpert.UI/Menu/MenuBar.razor.cs | 13 ++++--
src/EventLogExpert.UI/Menu/MenuBar.razor.css | 24 +++++------
src/EventLogExpert.UI/Modal/SidebarTabs.razor | 13 +++---
.../Modal/SidebarTabs.razor.cs | 13 +++---
.../Modal/SidebarTabs.razor.css | 10 ++---
src/EventLogExpert.UI/wwwroot/app.css | 8 ++++
.../Inputs/ButtonTests.cs | 37 ++++++++++++++++
.../LogTable/LogTablePaneSelectionTests.cs | 4 +-
25 files changed, 208 insertions(+), 172 deletions(-)
diff --git a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor
index 993ae5d9a..b8f644dc9 100644
--- a/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor
+++ b/src/EventLogExpert.UI/Dashboard/ScenarioDetail.razor
@@ -42,13 +42,11 @@
}
-
-
-
+ CssClass="scenario-detail__star"
+ IconClass="@(IsFavored ? "bi bi-star-fill" : "bi bi-star")"
+ OnClick="OnToggleFavorite" />
-
@Entry.FileName
+
@Entry.FileName
@if (ShouldShowBadge)
{
@BadgeLabel
diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs
index 3527f791b..0ea52ffff 100644
--- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs
+++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.cs
@@ -8,6 +8,7 @@
using EventLogExpert.Runtime.Menu;
using EventLogExpert.UI.Common;
using EventLogExpert.UI.Focus;
+using EventLogExpert.UI.Inputs;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
@@ -21,7 +22,7 @@ public sealed partial class DatabaseEntryRow : ComponentBase
private readonly string _nameButtonId = ComponentId.NewUnique("db-row-name").Value;
private readonly string _pendingStatusId = ComponentId.NewUnique("db-row-pending").Value;
- private ElementReference _nameButtonRef;
+ private ChromelessButton? _nameButton;
private bool _shouldFocusNameAfterRender;
private enum ActionKind
@@ -110,7 +111,10 @@ UpgradeProgress is null &&
[Inject] private ITraceLogger TraceLogger { get; init; } = null!;
- public ValueTask FocusNameAsync() => ElementFocus.SafelyAsync(_nameButtonRef, preventScroll: true);
+ public ValueTask FocusNameAsync() =>
+ _nameButton is { } button ?
+ ElementFocus.SafelyAsync(button.Element, preventScroll: true) :
+ ValueTask.CompletedTask;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
diff --git a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css
index 6839afa13..2cba15a24 100644
--- a/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css
+++ b/src/EventLogExpert.UI/Database/DatabaseEntryRow.razor.css
@@ -42,7 +42,7 @@
min-width: 0;
}
-.db-entry-name {
+.db-entry-row ::deep .db-entry-name {
flex: 1 1 auto;
min-width: 0;
diff --git a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor
index be0119fc5..fea0baccb 100644
--- a/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor
+++ b/src/EventLogExpert.UI/DatabaseTools/Tabs/ManageDatabasesTab.razor
@@ -4,7 +4,7 @@
@if (IsClassificationPending)
{
-
+
Classifying databases…
}
@@ -36,15 +36,13 @@
{