diff --git a/README.md b/README.md index e46653f..4106a97 100644 --- a/README.md +++ b/README.md @@ -48,10 +48,10 @@ Then build with Radzen components and the `ag-*` helper classes, and use the she - **`ag-*` helper classes** — cards, buttons, badges, forms, grids, empty states, the app shell, and the login card. - **Components** — `AppShell`, `AppPageTitle`, `LoginShell`, `AppIcon`, - `ThemeToggle`, `LanguageSwitcher`, `PageHeader`, `StatusBadge`, `EmptyState`, - `RowActions`, `IconButton`, `DataCard`, `GridToolbar`, `FilterBar`, `CardForm`, - `FormField`, `SettingToggleRow`, `StatTile`, `LinkButton`, `InfoBox`, and the - cascading `BreadcrumbState`. + `ThemeToggle`, `LanguageSwitcher`, `PageHeader`, `DialogHeader`, `StatusBadge`, + `EmptyState`, `RowActions`, `IconButton`, `DataCard`, `GridToolbar`, `FilterBar`, + `CardForm`, `FormField`, `SettingToggleRow`, `StatTile`, `LinkButton`, `InfoBox`, + and the cascading `BreadcrumbState`. - **Services & options** — `ConfirmService` (standardised confirm/delete dialogs) and `DesignBlazorOptions` (e.g. the `BrandName` `AppPageTitle` appends to document titles), registered via `services.AddDesignBlazor(o => o.BrandName = "Acme")` diff --git a/src/AndreGoepel.Design.Blazor/AndreGoepel.Design.Blazor.csproj b/src/AndreGoepel.Design.Blazor/AndreGoepel.Design.Blazor.csproj index bb69be9..519a271 100644 --- a/src/AndreGoepel.Design.Blazor/AndreGoepel.Design.Blazor.csproj +++ b/src/AndreGoepel.Design.Blazor/AndreGoepel.Design.Blazor.csproj @@ -7,7 +7,7 @@ AndreGoepel.Design.Blazor - 1.2.1 + 1.3.0 App-neutral design system for Blazor: emerald-accented light/dark tokens, a Radzen reskin, self-hosted fonts, EN/DE localization, and shell / login building blocks. blazor;design-system;radzen;theme;ui README.md diff --git a/src/AndreGoepel.Design.Blazor/Components/DialogHeader.razor b/src/AndreGoepel.Design.Blazor/Components/DialogHeader.razor new file mode 100644 index 0000000..3866e51 --- /dev/null +++ b/src/AndreGoepel.Design.Blazor/Components/DialogHeader.razor @@ -0,0 +1,23 @@ +@* Rich dialog title (DESIGN.md §5 "Dialogs"): a title + optional subtitle, with a + bottom border separating it from the dialog body — pass this as the + `titleContent` RenderFragment to `DialogService.OpenAsync` instead of a plain + string when the dialog needs more than a bare title. Radzen still renders its + own close button around whatever `titleContent` returns. *@ + +
+ @Title + @if (!string.IsNullOrWhiteSpace(Subtitle)) + { + @Subtitle + } +
+ +@code { + /// The dialog title. + [Parameter, EditorRequired] + public string Title { get; set; } = ""; + + /// Optional short description shown under the title. + [Parameter] + public string? Subtitle { get; set; } +} diff --git a/src/AndreGoepel.Design.Blazor/Components/FormField.razor b/src/AndreGoepel.Design.Blazor/Components/FormField.razor index 9b0acee..aa4180a 100644 --- a/src/AndreGoepel.Design.Blazor/Components/FormField.razor +++ b/src/AndreGoepel.Design.Blazor/Components/FormField.razor @@ -4,7 +4,13 @@ label doesn't match the design. *@ - + + @Label + @if (Required) + { + + } + @ChildContent @if (!string.IsNullOrWhiteSpace(Hint)) { @@ -30,4 +36,9 @@ /// Optional muted help text shown under the control. [Parameter] public string? Hint { get; set; } + + /// Whether to show a red asterisk after the label. Purely visual — pair + /// it with a RadzenRequiredValidator on the control for actual validation. + [Parameter] + public bool Required { get; set; } } diff --git a/src/AndreGoepel.Design.Blazor/DESIGN.md b/src/AndreGoepel.Design.Blazor/DESIGN.md index b9fdac2..988b0e1 100644 --- a/src/AndreGoepel.Design.Blazor/DESIGN.md +++ b/src/AndreGoepel.Design.Blazor/DESIGN.md @@ -148,7 +148,7 @@ label doesn't match the design. Put the control + any validators in each ```razor - + @@ -158,7 +158,9 @@ label doesn't match the design. Put the control + any validators in each `Submit` fires only when validation passes; while `IsBusy` is true the primary button shows `BusyText` and both buttons disable. `Cancel` is optional — omit it and -no cancel button is shown. +no cancel button is shown. `FormField`'s `Required` is purely the visual red +asterisk — it doesn't validate anything, so pair it with a +`RadzenRequiredValidator` as above. Two fields side by side (collapses to one column on narrow screens): wrap them in `
` inside the `CardForm`. @@ -437,6 +439,27 @@ private void OpenRowMenu(MouseEventArgs args, Role role) The host layout must render `` once (the Aspire sample does). +### Dialogs + +`DialogService.OpenAsync(string title, ...)` only takes a plain title string — +fine for a one-liner, but a form dialog that needs a subtitle (and a border +separating the header from the body) should pass `DialogHeader` as the +`titleContent` RenderFragment instead. Radzen still renders its own close button +and wrapper around whatever `titleContent` returns: + +```razor +var result = await Dialogs.OpenAsync( + titleContent: _ => + @, + childContent: _ => @ +); +``` + +Inside the dialog body, use `CardForm`/`FormField` as usual — a `FormField` +that's required visually should set `Required="true"` for the red asterisk +(pair it with a `RadzenRequiredValidator` for the actual validation), and pair +naturally-related fields (e.g. email + mobile) in an `ag-form-grid` as normal. + ### Confirmations Don't call `DialogService.Confirm` directly — inject `ConfirmService` and use it, @@ -483,9 +506,11 @@ A page just provides the heading block + form + a centred footer link: | Class | Purpose | |---|---| | `ag-page-head` | header row: heading left, action right (rendered by `PageHeader`) | +| `ag-dialog-header` | bordered dialog title + optional subtitle (rendered by `DialogHeader`) | | `ag-card-actions` | bordered card footer, right-aligned (add `ag-start` for left; rendered by `CardForm`) | | `ag-actions-inline` | inline button group that doesn't stretch | | `ag-form-grid` | two-column field grid (collapses ≤640px) | +| `ag-required` | red asterisk after a required field's label (rendered by `FormField`) | | `ag-toggle-row`, `ag-toggle-row-text`, `ag-toggle-row-label`, `ag-toggle-row-description` | settings toggle row: label + description + switch (rendered by `SettingToggleRow`) | | `ag-badge` + `ag-badge-success` / `-danger` / `-warn` / `-info` / `-neutral` | status pills (rendered by `StatusBadge`) | | `ag-grid-toolbar`, `ag-search`, `ag-search-icon`, `ag-search-input`, `ag-grid-count` | in-card grid toolbar: filter box + row count (rendered by `GridToolbar`, inside `DataCard`) | diff --git a/src/AndreGoepel.Design.Blazor/wwwroot/css/design.css b/src/AndreGoepel.Design.Blazor/wwwroot/css/design.css index b461c42..5ac0429 100644 --- a/src/AndreGoepel.Design.Blazor/wwwroot/css/design.css +++ b/src/AndreGoepel.Design.Blazor/wwwroot/css/design.css @@ -640,6 +640,12 @@ h4:focus-visible, h5:focus-visible, h6:focus-visible { } } +/* Required-field marker: a small red asterisk after a FormField's label. */ +.ag-required { + color: var(--ag-danger); + margin-left: 2px; +} + /* A settings toggle row: label + description on the left, a switch on the right. Stack them in a card; the last row should drop its border (add .ag-toggle-row-last, or let SettingToggleRow's own :last-child rule @@ -737,6 +743,17 @@ a:hover, .rz-link:hover { justify-content: flex-start; } +/* Rich dialog title (title + optional subtitle), rendered inside Radzen's own + title-bar chrome via `DialogHeader` — a bottom border separates it from the + dialog body, matching a card's ag-card-actions footer border. */ +.ag-dialog-header { + display: flex; + flex-direction: column; + gap: 4px; + padding-bottom: 14px; + border-bottom: 1px solid var(--ag-border); +} + /* ===================================================== APP SHELL (host) === */ .ag-shell { display: flex; diff --git a/tests/AndreGoepel.Design.Blazor.Tests/Components/DialogHeaderTests.cs b/tests/AndreGoepel.Design.Blazor.Tests/Components/DialogHeaderTests.cs new file mode 100644 index 0000000..a273cd2 --- /dev/null +++ b/tests/AndreGoepel.Design.Blazor.Tests/Components/DialogHeaderTests.cs @@ -0,0 +1,52 @@ +using AndreGoepel.Design.Blazor.Components; +using Bunit; + +namespace AndreGoepel.Design.Blazor.Tests.Components; + +public class DialogHeaderTests : BunitContext +{ + [Fact] + public void Render_TitleOnly_ShowsHeadingAndNoSubtitle() + { + // Act + var cut = Render(parameters => parameters.Add(p => p.Title, "New customer")); + + // Assert + Assert.Equal("New customer", cut.Find("h2").TextContent); + Assert.Empty(cut.FindAll("p.rz-text-body2")); + } + + [Fact] + public void Render_WithSubtitle_ShowsSubtitleText() + { + // Act + var cut = Render(parameters => + parameters + .Add(p => p.Title, "New customer") + .Add(p => p.Subtitle, "Add a customer to the studio's portal.") + ); + + // Assert + Assert.Contains("Add a customer to the studio's portal.", cut.Markup); + } + + [Fact] + public void Render_WithoutSubtitle_OmitsSubtitleParagraph() + { + // Act + var cut = Render(parameters => parameters.Add(p => p.Title, "New customer")); + + // Assert + Assert.DoesNotContain("rz-text-body2", cut.Markup); + } + + [Fact] + public void Render_UsesAgDialogHeaderClass() + { + // Act + var cut = Render(parameters => parameters.Add(p => p.Title, "New customer")); + + // Assert + Assert.NotNull(cut.Find(".ag-dialog-header")); + } +} diff --git a/tests/AndreGoepel.Design.Blazor.Tests/Components/FormFieldTests.cs b/tests/AndreGoepel.Design.Blazor.Tests/Components/FormFieldTests.cs index e471204..1b8e200 100644 --- a/tests/AndreGoepel.Design.Blazor.Tests/Components/FormFieldTests.cs +++ b/tests/AndreGoepel.Design.Blazor.Tests/Components/FormFieldTests.cs @@ -62,4 +62,27 @@ public void Render_WithoutHint_OmitsCaption() // Assert Assert.Empty(cut.FindAll(".rz-text-caption")); } + + [Fact] + public void Render_Required_ShowsAsteriskAfterLabel() + { + // Act + var cut = Render(parameters => + parameters.Add(p => p.Label, "Email").Add(p => p.Required, true) + ); + + // Assert + Assert.Contains("Email", cut.Find("label").TextContent); + Assert.NotNull(cut.Find("label .ag-required")); + } + + [Fact] + public void Render_NotRequired_OmitsAsterisk() + { + // Act + var cut = Render(parameters => parameters.Add(p => p.Label, "Email")); + + // Assert + Assert.Empty(cut.FindAll(".ag-required")); + } }