Version: 1.0
Last Updated: 2026-02-01
Related Feature: 045-ui-component-refactor-and-library.md
This document defines the naming conventions, parameter patterns, and design standards for Blazor components in the BudgetExperiment.Client project. All new components must follow these standards, and existing components should be migrated incrementally.
| Tier | Purpose | Domain Dependencies | Library Candidate |
|---|---|---|---|
| Tier 1 | Atomic UI primitives (Button, Modal, Icon, etc.) | None | β Yes |
| Tier 2 | Composite patterns (FormField, ConfirmDialog, etc.) | None | β Yes |
| Tier 3 | Domain components (TransactionForm, AccountCard, etc.) | Domain/Contracts | β No |
Location: Tier 1 and 2 components belong in Components/Common/. Tier 3 components go in domain-specific folders (Display/, Forms/, Calendar/, etc.).
| Pattern | Use Case | Examples |
|---|---|---|
Is* |
State or condition | IsVisible, IsDisabled, IsLoading, IsSubmitting, IsProcessing |
Is*Visible |
Visibility of sub-elements | IsCloseButtonVisible, IsDateVisible, IsActionsVisible |
Should* |
Behavioral flags | ShouldCloseOnOverlayClick, ShouldAutoFocus |
β Avoid:
Show*prefix (useIs*Visibleinstead)- Bare adjectives without prefix (
Visible,Disabled,Compact) β always prefix withIs*
| Pattern | Examples |
|---|---|
On{Event} |
OnClick, OnClose, OnSubmit, OnCancel, OnChange |
On{Subject}{Event} |
OnSortOrderChanged, OnItemSelected, OnTransactionDeleted |
β Avoid:
{Subject}ChangedwithoutOn*prefixHandle*prefix (use for private methods only)
Always use enum types for size and variant parameters.
// β
Good: Enum-based sizing
[Parameter]
public ButtonSize Size { get; set; } = ButtonSize.Medium;
// β Bad: String-based sizing
[Parameter]
public string Size { get; set; } = "medium";
// β Bad: Integer sizing (acceptable only for pixel-precise values like Icon)
[Parameter]
public int Size { get; set; } = 20;| Parameter | Type | Use Case |
|---|---|---|
ChildContent |
RenderFragment? |
Primary content slot |
{Name}Content |
RenderFragment? |
Named slots (HeaderContent, FooterContent, ActionsContent) |
Title |
string |
Text-only title |
Label |
string |
Form field labels |
Message |
string |
Notification/alert messages |
Capture unmatched attributes to allow HTML attribute passthrough:
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }All component enums should be defined in Components/Common/ComponentEnums.cs.
public enum ModalSize { Small, Medium, Large }
public enum SpinnerSize { Small, Medium, Large }public enum ButtonSize { Small, Medium, Large }
public enum ButtonVariant { Primary, Secondary, Success, Danger, Warning, Ghost, Outline }
public enum BadgeVariant { Default, Success, Warning, Danger, Info }
public enum IconSize { Small, Medium, Large, ExtraLarge }
public enum AlertVariant { Info, Success, Warning, Danger }Each Tier 1/2 component should follow this structure:
@* ComponentName.razor - Brief description *@
<div class="component-name @VariantClass @SizeClass @AdditionalClasses"
@attributes="AdditionalAttributes">
@* Component markup *@
</div>
@code {
// Parameters grouped by purpose
// 1. Content parameters
[Parameter]
public RenderFragment? ChildContent { get; set; }
// 2. Appearance parameters (variant, size, etc.)
[Parameter]
public ComponentVariant Variant { get; set; } = ComponentVariant.Default;
[Parameter]
public ComponentSize Size { get; set; } = ComponentSize.Medium;
// 3. State parameters (booleans)
[Parameter]
public bool IsDisabled { get; set; }
[Parameter]
public bool IsLoading { get; set; }
// 4. Event callbacks
[Parameter]
public EventCallback OnClick { get; set; }
// 5. Additional attributes (always last)
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
// 6. Computed properties
private string VariantClass => Variant switch { ... };
private string SizeClass => Size switch { ... };
}All public parameters must have XML documentation:
/// <summary>
/// Gets or sets a value indicating whether the button is disabled.
/// </summary>
[Parameter]
public bool IsDisabled { get; set; }Use lowercase kebab-case matching the component name:
| Component | Root Class |
|---|---|
Button.razor |
.btn |
Modal.razor |
.modal-dialog |
FormField.razor |
.form-group |
Badge.razor |
.badge |
Use BEM-like suffixes:
/* Size modifiers */
.btn-sm { }
.btn-md { }
.btn-lg { }
/* Variant modifiers */
.btn-primary { }
.btn-secondary { }
.btn-danger { }.is-disabled { }
.is-loading { }
.is-active { }
.has-error { }| Component | Status | CSS File |
|---|---|---|
Button.razor |
π Planned | buttons.css |
Badge.razor |
π Planned | badges.css |
Card.razor |
π Planned | cards.css |
EmptyState.razor |
π Planned | empty-state.css |
FormField.razor |
π Planned | forms.css |
Icon.razor |
β Exists | N/A (inline SVG) |
LoadingSpinner.razor |
β Exists | loading.css |
Modal.razor |
β Exists | modal.css |
ErrorAlert.razor |
β Exists | alerts.css |
ConfirmDialog.razor |
β Exists | Uses Modal |
PageHeader.razor |
β Exists | page-header.css |
ThemeToggle.razor |
β Exists | theme.css |
When standardizing an existing component:
- Rename
Show*parameters toIs*Visible - Add
Is*prefix to bare boolean parameters - Rename
{Event}Changedcallbacks toOn{Event}Changed - Convert magic strings to enums
- Add
AdditionalAttributesparameter if missing - Add XML documentation for all parameters
- Update consuming components/pages
Based on the audit of 29 components (2026-02-01):
| Component | Current | Target |
|---|---|---|
| Modal | ShowCloseButton |
IsCloseButtonVisible |
| PageHeader | ShowBackButton |
IsBackButtonVisible |
| ThemeToggle | ShowLabel |
IsLabelVisible |
| CategoryBudgetCard | ShowEditButton |
IsEditButtonVisible |
| MoneyDisplay | ShowColor |
IsColorCoded |
| MoneyDisplay | ShowPositiveSign |
ShouldShowPositiveSign |
| ScopeBadge | ShowLabel |
IsLabelVisible |
Chart components live in Components/Charts/ and must follow the same parameter and accessibility conventions.
- All chart SVGs must include
role="img"andaria-label. - Interactive segments (bars, points, donut segments) must be focusable and expose
aria-labeltext. - Tooltips should be visible on hover and keyboard focus, and cleared on blur.
- Legends must be keyboard navigable when interactive.
- Use typed data models (
BarChartGroup,LineData,DonutSegmentData) for rendering. - Avoid raw
Dictionary<string, decimal>unless the component explicitly supports multi-series. - Provide stable
Idvalues for segments when possible.
- Prefer CSS variables for colors and spacing (use design system tokens).
- Chart root classes use kebab-case:
.donut-chart,.bar-chart,.line-chart. - Tooltips should use consistent classes (
.donut-tooltip,.bar-tooltip,.chart-tooltip).
- Charts must handle empty data gracefully with an empty-state message.
- Hover/focus interactions must not require a mouse (keyboard supported).
- Avoid third-party charting libraries; charts are pure SVG.
| TransactionTable |
ShowDate|IsDateVisible| | TransactionTable |ShowActions|IsActionsVisible| | TransactionTable |ShowBalance|IsBalanceVisible| | CategoryForm |ShowSortOrder|IsSortOrderVisible| | TransactionForm |ShowAccountSelector|IsAccountSelectorVisible|
| Component | Current | Target |
|---|---|---|
| LoadingSpinner | FullPage |
IsFullPage |
| Modal | CloseOnOverlayClick |
ShouldCloseOnOverlayClick |
| BudgetProgressBar | Compact |
IsCompact |
| Component | Current | Target |
|---|---|---|
| CategoryForm | SortOrderChanged |
OnSortOrderChanged |
| Component | Parameter | Suggested Enum |
|---|---|---|
| BudgetProgressBar | Status |
BudgetStatus |
| ScopeBadge | Scope |
AccountScope |
| Icon | Size (int) |
Keep int for pixel precision |
Each component must have bUnit tests covering:
- Rendering: Component renders without errors
- Parameters: Each parameter affects output correctly
- Events: EventCallbacks fire with correct arguments
- State: State changes (loading, disabled) render correctly
- Accessibility: ARIA attributes present where applicable
tests/BudgetExperiment.Client.Tests/Components/Common/ButtonTests.cs
tests/BudgetExperiment.Client.Tests/Components/Common/ModalTests.cs
@* Button.razor - Standardized button component *@
<button class="btn @VariantClass @SizeClass"
type="@Type"
disabled="@(IsDisabled || IsLoading)"
@onclick="HandleClick"
@attributes="AdditionalAttributes">
@if (IsLoading)
{
<LoadingSpinner Size="SpinnerSize.Small" />
}
else if (!string.IsNullOrEmpty(IconLeft))
{
<Icon Name="@IconLeft" Size="16" />
}
@ChildContent
@if (!string.IsNullOrEmpty(IconRight))
{
<Icon Name="@IconRight" Size="16" />
}
</button>
@code {
[Parameter] public RenderFragment? ChildContent { get; set; }
[Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary;
[Parameter] public ButtonSize Size { get; set; } = ButtonSize.Medium;
[Parameter] public string Type { get; set; } = "button";
[Parameter] public bool IsDisabled { get; set; }
[Parameter] public bool IsLoading { get; set; }
[Parameter] public string? IconLeft { get; set; }
[Parameter] public string? IconRight { get; set; }
[Parameter] public EventCallback OnClick { get; set; }
[Parameter(CaptureUnmatchedValues = true)]
public Dictionary<string, object>? AdditionalAttributes { get; set; }
private string VariantClass => Variant switch
{
ButtonVariant.Secondary => "btn-secondary",
ButtonVariant.Success => "btn-success",
ButtonVariant.Danger => "btn-danger",
ButtonVariant.Warning => "btn-warning",
ButtonVariant.Ghost => "btn-ghost",
ButtonVariant.Outline => "btn-outline",
_ => "btn-primary"
};
private string SizeClass => Size switch
{
ButtonSize.Small => "btn-sm",
ButtonSize.Large => "btn-lg",
_ => string.Empty
};
private async Task HandleClick() => await OnClick.InvokeAsync();
}For pages with complex @code blocks (many handlers, state fields, computed properties), extract the logic into a plain C# ViewModel class. This eliminates the async state-machine coverage instrumentation gap in Razor files and improves testability.
Apply ViewModel extraction when a page has:
- β₯ 5 handler methods in the
@codeblock - β₯ 5 state fields managing UI state
- Low coverage due to Razor
@codeasync handler instrumentation gaps
src/BudgetExperiment.Client/ViewModels/{PageName}ViewModel.cs
tests/BudgetExperiment.Client.Tests/ViewModels/{PageName}ViewModelTests.cs
- Sealed class implementing
IDisposable(if it subscribes to events). - Constructor receives services via DI β same services the page previously injected.
- Public properties with
private setfor state (bound by the Razor page). - Public methods for all handler logic.
Action? OnStateChangedcallback β the Razor page wires this toInvokeAsync(StateHasChanged).- Call
OnStateChanged?.Invoke()after any state mutation that should trigger a re-render.
public sealed class ExampleViewModel : IDisposable
{
public bool IsLoading { get; private set; }
public Action? OnStateChanged { get; set; }
public async Task InitializeAsync()
{
IsLoading = true;
OnStateChanged?.Invoke();
// ... load data ...
IsLoading = false;
OnStateChanged?.Invoke();
}
public void Dispose() { /* unsubscribe from events */ }
}Register ViewModels as Transient (one instance per page render):
builder.Services.AddTransient<ExampleViewModel>();The page becomes a thin binding layer β inject the ViewModel, bind properties, delegate events:
@page "/example"
@inject ExampleViewModel ViewModel
@implements IDisposable
@if (ViewModel.IsLoading) { <LoadingSpinner /> }
<button @onclick="ViewModel.DoSomethingAsync">Action</button>
@code {
protected override async Task OnInitializedAsync()
{
ViewModel.OnStateChanged = () => InvokeAsync(StateHasChanged);
await ViewModel.InitializeAsync();
}
public void Dispose()
{
ViewModel.OnStateChanged = null;
ViewModel.Dispose();
}
}ViewModel tests are plain xUnit β no bUnit required for logic. Use stubs for injected services:
[Fact]
public async Task InitializeAsync_LoadsData()
{
var stub = new StubApiService { ... };
var vm = new ExampleViewModel(stub);
await vm.InitializeAsync();
vm.Items.ShouldNotBeEmpty();
}Existing bUnit page tests continue to serve as integration tests verifying the Razor β ViewModel β DOM pipeline.
See CategoriesViewModel (Feature 097) as the prototype:
- ViewModel:
src/BudgetExperiment.Client/ViewModels/CategoriesViewModel.cs - Tests:
tests/BudgetExperiment.Client.Tests/ViewModels/CategoriesViewModelTests.cs - Page:
src/BudgetExperiment.Client/Pages/Categories.razor
| Date | Version | Changes |
|---|---|---|
| 2026-02-01 | 1.0 | Initial standards based on component audit |
| 2026-07-14 | 1.1 | Added Section 11: ViewModel Extraction Pattern (Feature 097) |