diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/alert-service.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/alert-service.txt new file mode 100644 index 00000000..fc6dc3c6 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/alert-service.txt @@ -0,0 +1,18 @@ +@inject DialogService DialogService + + private async Task HandleAlert() +{ + alertStatus = "Waiting..."; + await DialogService.AlertAsync( + "Session Expired", + "Please log in again."); +} + +// With custom options: +var confirmed = await DialogService.ConfirmAsync( + "Delete item?", + "This action cannot be undone.", + new AlertDialogOptions + { + ButtonText = "Ok, I Accept!", + }); diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/confirm-service.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/confirm-service.txt index b6741748..efe86641 100644 --- a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/confirm-service.txt +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/confirm-service.txt @@ -2,7 +2,7 @@ private async Task HandleDelete() { - var confirmed = await DialogService.Confirm( + var confirmed = await DialogService.ConfirmAsync( "Delete item?", "This action cannot be undone."); @@ -13,7 +13,7 @@ private async Task HandleDelete() } // With custom options: -var confirmed = await DialogService.Confirm( +var confirmed = await DialogService.ConfirmAsync( "Delete item?", "This action cannot be undone.", new ConfirmDialogOptions @@ -21,4 +21,4 @@ var confirmed = await DialogService.Confirm( ConfirmText = "Yes, delete", CancelText = "Keep it", Destructive = true - }); \ No newline at end of file + }); diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/custom-service.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/custom-service.txt new file mode 100644 index 00000000..1a8d1042 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/custom-service.txt @@ -0,0 +1,22 @@ +@inject DialogService DialogService + +private async Task HandleCustomDialog() +{ + var result = await DialogService.OpenAsync( + new Dictionary + { + [nameof(EditUserComponent.UserId)] = 42 + }, + new DialogOpenOptions + { + Title = "Edit User", + Size = DialogSize.Large + }); + + if (result.Cancelled) + { + return; + } + + var data = result.GetData(); +} diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/form-in-dialog.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/form-in-dialog.txt new file mode 100644 index 00000000..bafa33cf --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/form-in-dialog.txt @@ -0,0 +1,52 @@ + + + Open Form Dialog + + + + + Create Account + + Fill in the details below to create a new account. + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + Cancel + + Create Account + +
+
+ +@code { + private string? formFirstName; + private string? formLastName; + private string? formEmail; + private string? formBio; + + private void HandleCreateAccount() + { + // Handle account creation + } +} diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/nested-dialog.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/nested-dialog.txt new file mode 100644 index 00000000..1dbb83cf --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/nested-dialog.txt @@ -0,0 +1,53 @@ + + + Open Outer Dialog + + + + + Outer Dialog + + This is the outer dialog. Click the button below to open a nested dialog inside. + + + +
+
+ + +
+ + + + Open Inner Dialog + + + + + Inner Dialog + + This is the nested inner dialog. Press Escape — only this dialog should close. + + + +
+ + +
+ + + + Close Inner + + +
+
+
+ + + + Close Outer + + +
+
diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/non-dismissible.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/non-dismissible.txt new file mode 100644 index 00000000..19c4d032 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/non-dismissible.txt @@ -0,0 +1,19 @@ + + + Open Dialog + + + + + Non-Dismissible + + Clicking the backdrop will not close this dialog. Use the button below or press Escape. + + + + + Got it + + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/prompt-service.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/prompt-service.txt new file mode 100644 index 00000000..2914f2c9 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/prompt-service.txt @@ -0,0 +1,17 @@ +@inject DialogService DialogService + +private async Task HandlePrompt() +{ + var name = await DialogService.PromptAsync( + "Rename File", + "Enter a new file name:", + new PromptDialogOptions + { + DefaultValue = "document.txt", + Placeholder = "file-name.txt", + Required = true, + MaxLength = 50 + }); + + var text = name.Value ?? "Cancelled"; +} diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor index 0f5ffc15..c542ccd5 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor @@ -298,6 +298,7 @@ Pineapple +

Selected: @(selectedDialogFruit ?? "None")

@@ -309,6 +310,7 @@ SearchPlaceholder="Search framework..." EmptyMessage="No framework found." MatchTriggerWidth="true" /> +

Selected: @(selectedComboboxFramework ?? "None")

@@ -320,6 +322,7 @@ SearchPlaceholder="Search technologies..." EmptyMessage="No technologies found." MatchTriggerWidth="true" /> +

Selected: @(selectedTechnologies?.Any() == true ? string.Join(", ", selectedTechnologies) : "None")

@@ -394,6 +397,84 @@ +
+

+ Alert Service + + New + +

+ +

+ Use DialogService.Alert() for simple acknowledgment dialogs. + Returns a Task that completes when the user clicks OK. +

+ +
+ + Show Alert + + + + Status: @alertStatus + +
+ + +
+ +
+

+ Prompt Service + + New + +

+ +

+ Use DialogService.Prompt() to collect text input asynchronously. + Returns string?. +

+ +
+ + Rename File + + + + Result: @promptResult + +
+ + +
+ +
+

+ Custom Component Dialog + + New + +

+ +

+ Use DialogService.Open<TComponent>() to render a fully custom dialog component. + Returns a DialogResult. +

+ +
+ + Edit User + + + + Result: @customResult + +
+ + +
+

Form in Dialog

@@ -441,6 +522,8 @@ + +

@@ -503,6 +586,8 @@ + +
@@ -531,6 +616,8 @@ + +
@@ -564,11 +651,14 @@ Controls whether the dialog is open (controlled mode). When null, the dialog manages its own state. + + Two-way binding callback for the open state. Use with @@bind-Open. + Default open state when in uncontrolled mode. - Whether the dialog can be dismissed by clicking outside or pressing Escape. + Whether the dialog is modal. Modal dialogs trap focus and lock scroll. Event callback invoked when the dialog open state changes. @@ -576,6 +666,9 @@ + + Additional CSS classes to apply to the dialog content container. + Whether to show the close button (X icon). @@ -591,6 +684,12 @@ Whether to lock body scroll when dialog is open. + + Render inline within a named container instead of portaling to the document body. + + + Event callback invoked when the Escape key is pressed inside the dialog. + @@ -598,18 +697,148 @@ When true, passes trigger behavior to child components instead of rendering its own button. + + + + When true, passes close behavior to child components instead of rendering its own button. + + + Custom click handler invoked after the dialog is closed. + + + When true, prevents the default close behavior. Useful for validation before closing. + + + + + + Additional CSS classes for the header container. + + + + + + Additional CSS classes for the footer container. + + + + + + Additional CSS classes for the title element. + + + The HTML element tag to render for the title. + + + + + + Additional CSS classes for the description element. + + + + + + +
+
+

DialogService API

+

Programmatic dialog methods available via dependency injection.

+
+
+ + + Shows a confirm dialog. Returns a result with Confirmed (bool) property. + + + Shows an alert dialog with a single acknowledgment button. Cannot be dismissed via backdrop or Escape. + + + Shows a prompt dialog that collects text input. Returns a result with Value (string?) property. + + + Opens a custom component inside a dialog. The component receives IDialogReference as a cascading parameter. + + + + + + The label for the confirm button. + + + The label for the cancel button. + + + Whether the confirm button should use the destructive variant. + + + + + + The label for the acknowledgment button. + + + + + + The label for the confirm button. + + + The label for the cancel button. + + + The initial value of the input field. + + + Placeholder text displayed in the input field. + + + Whether the input field is required. Disables the confirm button when empty. + + + Maximum allowed length of the input value. + + + + + + Optional dialog title. If null, the component may render its own header. + + + Whether the close (X) button should be displayed. + + + Controls the dialog width preset. Options: Small, Default, Large, ExtraLarge, Full. + + + Prevents closing via Escape key or backdrop click. + + + + + + Closes the dialog with a specified DialogResult. Use DialogResult.Ok(data) to return data. + + + Cancels the dialog. Equivalent to closing with DialogResult.Cancel(). + +
@code { - private string confirmResult = "None"; private bool isDialogOpen = false; private DateTime? dialogDatePickerValue; private string? selectedComboboxFramework; private string? selectedDialogFruit; private IEnumerable? selectedTechnologies = new List(); + private string confirmResult = "None"; + private string alertStatus = "Idle"; + private string promptResult = "-"; + private string customResult = "-"; + // Form in Dialog private string? formFirstName; private string? formLastName; @@ -663,16 +892,16 @@ private async Task HandleConfirmDelete() { - var confirmed = await DialogService.Confirm( + var result = await DialogService.ConfirmAsync( "Delete item?", "This action cannot be undone. The item will be permanently removed."); - confirmResult = confirmed ? "Confirmed" : "Cancelled"; + confirmResult = result.Confirmed ? "Confirmed" : "Cancelled"; } private async Task HandleDestructiveDelete() { - var confirmed = await DialogService.Confirm( + var data = await DialogService.ConfirmAsync( "Delete item?", "This action cannot be undone.", new ConfirmDialogOptions @@ -682,6 +911,102 @@ Destructive = true }); - confirmResult = confirmed ? "Deleted!" : "Kept"; + confirmResult = data.Confirmed ? "Deleted!" : "Kept"; + } + + private async Task HandleAlert() + { + alertStatus = "Waiting..."; + await DialogService.AlertAsync( + "Session Expired", + "Please log in again."); + + alertStatus = "Acknowledged"; + } + + private async Task HandlePrompt() + { + var name = await DialogService.PromptAsync( + "Rename File", + "Enter a new file name:", + new PromptDialogOptions + { + DefaultValue = "document.txt", + Placeholder = "file-name.txt", + Required = true, + MaxLength = 50 + }); + + promptResult = name.Value ?? "Cancelled"; + } + + private async Task HandleCustomDialog() + { + var result = await DialogService.OpenAsync( + new Dictionary + { + ["UserId"] = 42 + }, + new DialogOpenOptions + { + Title = "Edit User", + Size = DialogSize.Large + }); + + if (!result.Cancelled) + { + var data = result.GetData(); + customResult = $"Saved: {data}"; + } + else + { + customResult = "Cancelled"; + } } + + public sealed class EditUserDemoComponent : ComponentBase + { + [Parameter] public int UserId { get; set; } + + [CascadingParameter] + public IDialogReference DialogRef { get; set; } = default!; + + private string? userName; + private string? userEmail; + + protected override void OnInitialized() + { + // Simulate loading user data + userName = $"User {UserId}"; + userEmail = "example@company.com"; + } + + private Task Save() + { + var dto = new UserDto( + UserId, + userName ?? string.Empty, + userEmail ?? string.Empty); + + return DialogRef.CloseAsync(DialogResult.Ok(dto)); + } + + private Task Cancel() + => DialogRef.CancelAsync(); + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder) + { +
+
Editing User @UserId
+ + +
+ Cancel + Save +
+
+ } + } + + public sealed record UserDto(int Id, string Name, string Email); } diff --git a/src/BlazorBlueprint.Components/Components/Chart/Series/BbBar.razor.cs b/src/BlazorBlueprint.Components/Components/Chart/Series/BbBar.razor.cs index 5098b702..16dea820 100644 --- a/src/BlazorBlueprint.Components/Components/Chart/Series/BbBar.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Chart/Series/BbBar.razor.cs @@ -57,7 +57,7 @@ public partial class BbBar : SeriesBase /// Gets or sets the position of the data label relative to the bar. /// /// - /// Default is for vertical bars. + /// Default is for vertical bars. /// [Parameter] public LabelPosition LabelPosition { get; set; } = LabelPosition.Top; diff --git a/src/BlazorBlueprint.Components/Components/Chart/Series/BbPie.razor.cs b/src/BlazorBlueprint.Components/Components/Chart/Series/BbPie.razor.cs index a1483a51..eaa9e311 100644 --- a/src/BlazorBlueprint.Components/Components/Chart/Series/BbPie.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Chart/Series/BbPie.razor.cs @@ -70,7 +70,7 @@ public partial class BbPie : SeriesBase /// Gets or sets the label position relative to pie slices. /// /// - /// Default is . Use + /// Default is . Use /// to render labels within slice areas. /// [Parameter] diff --git a/src/BlazorBlueprint.Components/Components/Dialog/AlertDialogData.cs b/src/BlazorBlueprint.Components/Components/Dialog/AlertDialogData.cs new file mode 100644 index 00000000..48e11bc1 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/AlertDialogData.cs @@ -0,0 +1,16 @@ +namespace BlazorBlueprint.Components; + +/// +/// Represents a pending alert dialog managed by . +/// +/// +/// An alert dialog presents a message with a single acknowledgment button. +/// It resolves with a confirmed when dismissed. +/// +public sealed class AlertDialogData : DialogData +{ + /// + /// Gets or sets the customization options for the alert dialog. + /// + public AlertDialogOptions Options { get; set; } = new(); +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/AlertDialogOptions.cs b/src/BlazorBlueprint.Components/Components/Dialog/AlertDialogOptions.cs new file mode 100644 index 00000000..c67ca057 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/AlertDialogOptions.cs @@ -0,0 +1,13 @@ +namespace BlazorBlueprint.Components; + +/// +/// Options for customizing an alert dialog shown via . +/// +public sealed class AlertDialogOptions +{ + /// + /// The label for the acknowledgment button. + /// Default: "OK". + /// + public string ButtonText { get; set; } = "OK"; +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/BbDialogProvider.razor b/src/BlazorBlueprint.Components/Components/Dialog/BbDialogProvider.razor index b8b2d968..1e34f371 100644 --- a/src/BlazorBlueprint.Components/Components/Dialog/BbDialogProvider.razor +++ b/src/BlazorBlueprint.Components/Components/Dialog/BbDialogProvider.razor @@ -1,6 +1,9 @@ @namespace BlazorBlueprint.Components -@implements IDisposable +@using BlazorBlueprint.Components.Components.Dialog.Internals +@implements IAsyncDisposable @inject DialogService DialogService +@inject IFocusManager FocusManager +@inject IJSRuntime JSRuntime @* Dialog Provider - renders programmatic confirm dialogs from DialogService. @@ -10,62 +13,222 @@ @foreach (var dialog in DialogService.Dialogs) {
+ @* Overlay *@ -
+
@* Content *@ -
+ @switch (dialog) + { + case ConfirmDialogData confirm: + + break; + + case AlertDialogData alert: + + break; + + case PromptDialogData prompt: + + break; - @* Header *@ -
-

@dialog.Title

- @if (!string.IsNullOrEmpty(dialog.Description)) - { -

@dialog.Description

- } -
- - @* Footer *@ -
- - @dialog.Options.CancelText - - - @dialog.Options.ConfirmText - -
+ case ComponentDialogData component: + + break; + }
} @code { + private const string DialogContainerBaseClass = + "fixed left-[50%] top-[50%] z-50 grid w-full " + + "translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg sm:rounded-lg animate-in fade-in-0 zoom-in-95"; + + private ElementReference contentRef; + private IAsyncDisposable? focusTrap; + private IJSObjectReference? portalModule; + private IJSObjectReference? scrollLockCleanup; + private IJSObjectReference? escapeModule; + private DotNetObjectReference? dotNetRef; + private readonly string instanceId = Guid.NewGuid().ToString("N"); + private bool isInitialized; + private bool disposed; + protected override void OnInitialized() + => DialogService.OnChange += HandleChange; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (DialogService.Dialogs.Count > 0 && !isInitialized) + { + isInitialized = true; + + // Focus trap + try + { + focusTrap = await FocusManager.TrapFocus(contentRef); + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + } + catch (InvalidOperationException) + { + } + + // Scroll lock + try + { + portalModule = await JSRuntime.InvokeAsync( + "import", "./_content/BlazorBlueprint.Primitives/js/primitives/portal.js"); + scrollLockCleanup = await portalModule.InvokeAsync("lockBodyScroll"); + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + } + catch (InvalidOperationException) + { + } + + // Escape key + try + { + escapeModule = await JSRuntime.InvokeAsync( + "import", "./_content/BlazorBlueprint.Primitives/js/primitives/escape-keydown.js"); + dotNetRef = DotNetObjectReference.Create(this); + await escapeModule.InvokeVoidAsync("initialize", dotNetRef, instanceId); + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + } + catch (InvalidOperationException) + { + } + } + else if (DialogService.Dialogs.Count == 0 && isInitialized) + { + await CleanupJsAsync(); + } + } + + [JSInvokable] + public void JsOnEscapeKey() { - DialogService.OnChange += HandleChange; + if (disposed || DialogService.Dialogs.Count == 0) + { + return; + } + + var topmost = DialogService.Dialogs[^1]; + InvokeAsync(() => TryBackdropClose(topmost)); } private void HandleChange() + => InvokeAsync(StateHasChanged); + + private void TryBackdropClose(DialogData dialog) { - InvokeAsync(StateHasChanged); + if (dialog is AlertDialogData) + { + return; + } + + if (dialog is ComponentDialogData component && + component.Options.PreventClose) + { + return; + } + + DialogService.Resolve(dialog.Id, DialogResult.Cancel()); } - private void HandleConfirm(string id) + private static string GetDialogContainerClass(DialogData dialog) { - DialogService.Resolve(id, true); + var sizeClass = dialog is ComponentDialogData component + ? component.Options.Size switch + { + DialogSize.Small => "max-w-sm", + DialogSize.Large => "max-w-2xl", + DialogSize.ExtraLarge => "max-w-4xl", + DialogSize.Full => "max-w-[95vw]", + _ => "max-w-lg" + } + : "max-w-lg"; + + return $"{DialogContainerBaseClass} {sizeClass}"; } - private void HandleCancel(string id) + private async Task CleanupJsAsync() { - DialogService.Resolve(id, false); + if (focusTrap != null) + { + try + { + await focusTrap.DisposeAsync(); + } + catch + { + } + focusTrap = null; + } + + if (scrollLockCleanup != null) + { + try + { + await scrollLockCleanup.InvokeVoidAsync("apply"); + } + catch + { + } + scrollLockCleanup = null; + } + + if (escapeModule != null) + { + try + { + await escapeModule.InvokeVoidAsync("dispose", instanceId); + await escapeModule.DisposeAsync(); + } + catch + { + } + escapeModule = null; + } + + if (portalModule != null) + { + try + { + await portalModule.DisposeAsync(); + } + catch + { + } + portalModule = null; + } + + dotNetRef?.Dispose(); + dotNetRef = null; + isInitialized = false; } - public void Dispose() + public async ValueTask DisposeAsync() { + if (disposed) + { + return; + } + + disposed = true; DialogService.OnChange -= HandleChange; + await CleanupJsAsync(); DialogService.CancelAll(); } } diff --git a/src/BlazorBlueprint.Components/Components/Dialog/ComponentDialogData.cs b/src/BlazorBlueprint.Components/Components/Dialog/ComponentDialogData.cs new file mode 100644 index 00000000..a5c43f98 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/ComponentDialogData.cs @@ -0,0 +1,18 @@ +namespace BlazorBlueprint.Components; + +public sealed class ComponentDialogData : DialogData, IDialogReference +{ + public required Type ComponentType { get; init; } + + public Dictionary Parameters { get; init; } = new(); + + public DialogOpenOptions Options { get; init; } = new(); + + internal Func? CloseCallback { get; init; } + + public Task CloseAsync(DialogResult result) + => CloseCallback?.Invoke(result) ?? Task.CompletedTask; + + public Task CancelAsync() + => CloseCallback?.Invoke(DialogResult.Cancel()) ?? Task.CompletedTask; +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/ConfirmDialogData.cs b/src/BlazorBlueprint.Components/Components/Dialog/ConfirmDialogData.cs index 83b478d3..240cb68b 100644 --- a/src/BlazorBlueprint.Components/Components/Dialog/ConfirmDialogData.cs +++ b/src/BlazorBlueprint.Components/Components/Dialog/ConfirmDialogData.cs @@ -3,30 +3,14 @@ namespace BlazorBlueprint.Components; /// /// Represents a pending confirm dialog managed by . /// -public class ConfirmDialogData +/// +/// A confirm dialog presents confirm and cancel actions. +/// The result indicates whether the user confirmed the action. +/// +public sealed class ConfirmDialogData : DialogData { /// - /// Unique identifier for the dialog instance. - /// - public string Id { get; set; } = Guid.NewGuid().ToString(); - - /// - /// The dialog title. - /// - public string Title { get; set; } = string.Empty; - - /// - /// The dialog description/message. - /// - public string? Description { get; set; } - - /// - /// Customization options for the dialog. + /// Gets or sets customization options for the dialog. /// public ConfirmDialogOptions Options { get; set; } = new(); - - /// - /// The TaskCompletionSource that resolves when the user responds. - /// - internal TaskCompletionSource Tcs { get; set; } = new(); } diff --git a/src/BlazorBlueprint.Components/Components/Dialog/ConfirmDialogOptions.cs b/src/BlazorBlueprint.Components/Components/Dialog/ConfirmDialogOptions.cs index 652c4f12..aa804069 100644 --- a/src/BlazorBlueprint.Components/Components/Dialog/ConfirmDialogOptions.cs +++ b/src/BlazorBlueprint.Components/Components/Dialog/ConfirmDialogOptions.cs @@ -3,17 +3,12 @@ namespace BlazorBlueprint.Components; /// /// Options for customizing a confirm dialog shown via . /// -public class ConfirmDialogOptions +public class ConfirmDialogOptions : DialogOptions { - /// - /// The label for the confirm/action button. Default: "Continue". - /// - public string ConfirmText { get; set; } = "Continue"; - - /// - /// The label for the cancel button. Default: "Cancel". - /// - public string CancelText { get; set; } = "Cancel"; + public ConfirmDialogOptions() + { + ConfirmText = "Continue"; + } /// /// Whether the confirm button should use the destructive variant. diff --git a/src/BlazorBlueprint.Components/Components/Dialog/ConfirmDialogResult.cs b/src/BlazorBlueprint.Components/Components/Dialog/ConfirmDialogResult.cs new file mode 100644 index 00000000..cf06f66b --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/ConfirmDialogResult.cs @@ -0,0 +1,30 @@ +namespace BlazorBlueprint.Components; + +/// +/// Represents the result of a confirm dialog interaction. +/// +/// +/// A confirm dialog presents confirm and cancel actions to the user. +/// This type provides a strongly typed abstraction over +/// for convenience. +/// +public sealed class ConfirmDialogResult : DialogResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The underlying dialog result. + internal ConfirmDialogResult(DialogResult result) + : base(result.Cancelled, result.Data) + { + } + + /// + /// Gets a value indicating whether the user confirmed the dialog. + /// + /// + /// if the dialog was confirmed; + /// otherwise, . + /// + public bool Confirmed => !Cancelled; +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/DialogData.cs b/src/BlazorBlueprint.Components/Components/Dialog/DialogData.cs new file mode 100644 index 00000000..2a1b0bef --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/DialogData.cs @@ -0,0 +1,47 @@ +namespace BlazorBlueprint.Components; + +/// +/// Represents the base model for all dialog instances managed by . +/// +/// +/// This type provides shared metadata and lifecycle management for dialog instances. +/// All dialogs resolve with a . +/// +public abstract class DialogData +{ + private readonly TaskCompletionSource tcs = new(); + + /// + /// Gets or sets the dialog description or message content. + /// + public string? Description { get; set; } + + /// + /// Gets or sets the unique identifier for the dialog instance. + /// + /// + /// This identifier is generated automatically and is used internally + /// by to resolve dialog instances. + /// + public string Id { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the dialog title. + /// + public string Title { get; set; } = string.Empty; + + /// + /// Gets the task that completes when the dialog is resolved. + /// + /// + /// This task is awaited internally by . + /// + internal Task Completion => tcs.Task; + + /// + /// Resolves the dialog with the specified result. + /// + /// The result supplied by the dialog renderer. + internal void SetResult(DialogResult result) + => tcs.TrySetResult(result); +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/DialogOpenOptions.cs b/src/BlazorBlueprint.Components/Components/Dialog/DialogOpenOptions.cs new file mode 100644 index 00000000..5bea301d --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/DialogOpenOptions.cs @@ -0,0 +1,31 @@ +namespace BlazorBlueprint.Components; + +/// +/// Options controlling behavior and appearance of a dialog opened via +/// . +/// +public sealed class DialogOpenOptions +{ + /// + /// Optional dialog title. If null, the component may render its own header. + /// + public string? Title { get; set; } + + /// + /// Whether the close (X) button should be displayed. + /// Default: true. + /// + public bool ShowClose { get; set; } = true; + + /// + /// Controls the dialog width preset. + /// Default: . + /// + public DialogSize Size { get; set; } = DialogSize.Default; + + /// + /// Prevents closing via Escape key or backdrop click. + /// Default: false. + /// + public bool PreventClose { get; set; } +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/DialogOptions.cs b/src/BlazorBlueprint.Components/Components/Dialog/DialogOptions.cs new file mode 100644 index 00000000..07bfa6bb --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/DialogOptions.cs @@ -0,0 +1,14 @@ +namespace BlazorBlueprint.Components; + +public abstract class DialogOptions +{ + /// + /// The label for the confirm/action button. Default: "OK". + /// + public string ConfirmText { get; set; } = "OK"; + + /// + /// The label for the cancel button. Default: "Cancel". + /// + public string CancelText { get; set; } = "Cancel"; +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/DialogResult.cs b/src/BlazorBlueprint.Components/Components/Dialog/DialogResult.cs new file mode 100644 index 00000000..fca7ca2c --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/DialogResult.cs @@ -0,0 +1,71 @@ +namespace BlazorBlueprint.Components; + +/// +/// Represents the result of a dialog interaction. +/// +/// +/// A dialog result indicates whether the dialog was cancelled and may +/// optionally carry additional data supplied by the dialog. +/// +public class DialogResult +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// if the dialog was cancelled; otherwise, . + /// + /// + /// Optional data returned by the dialog. + /// + protected DialogResult(bool cancelled, object? data = null) + { + Cancelled = cancelled; + Data = data; + } + + /// + /// Gets a value indicating whether the dialog was cancelled. + /// + /// + /// if the user dismissed or cancelled the dialog; + /// otherwise, . + /// + public bool Cancelled { get; } + + /// + /// Gets the raw data returned by the dialog. + /// + /// + /// The concrete data type depends on the dialog implementation. + /// This value is intended for internal use. Consumers should prefer + /// strongly typed wrappers or . + /// + internal object? Data { get; } + + /// + /// Attempts to retrieve the dialog data as the specified type. + /// + /// The expected data type. + /// + /// The data cast to if compatible; + /// otherwise, the default value of . + /// + public T? GetData() + => Data is T typed ? typed : default; + + /// + /// Creates a cancelled dialog result. + /// + /// A representing a cancelled dialog. + public static DialogResult Cancel() + => new(true); + + /// + /// Creates a successful dialog result. + /// + /// Optional data returned by the dialog. + /// A representing a successful dialog. + public static DialogResult Ok(object? data = null) + => new(false, data); +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/DialogService.cs b/src/BlazorBlueprint.Components/Components/Dialog/DialogService.cs index 149c0ca4..58698e65 100644 --- a/src/BlazorBlueprint.Components/Components/Dialog/DialogService.cs +++ b/src/BlazorBlueprint.Components/Components/Dialog/DialogService.cs @@ -1,31 +1,67 @@ + +using Microsoft.AspNetCore.Components; + namespace BlazorBlueprint.Components; /// -/// Service for showing programmatic confirm dialogs. -/// Register as scoped in DI for Blazor Server user isolation. +/// Provides functionality for showing and managing dialogs. /// +/// +/// Register as scoped in DI for proper isolation in Blazor Server applications. +/// All dialog operations resolve with a . +/// public class DialogService { - private readonly List dialogs = new(); + private readonly List dialogs = new(); /// - /// Event fired when the dialog collection changes. + /// Occurs when the dialog collection changes. /// public event Action? OnChange; /// /// Gets the current list of pending dialogs. /// - public IReadOnlyList Dialogs => dialogs.AsReadOnly(); + public IReadOnlyList Dialogs => dialogs.AsReadOnly(); /// - /// Shows a confirm dialog and returns true if confirmed, false if cancelled. + /// Shows an alert dialog with a single acknowledgment button. + /// + /// + /// A task that completes when the user dismisses the dialog. + /// + public Task AlertAsync( + string title, + string? description = null, + AlertDialogOptions? options = null) + { + var data = new AlertDialogData + { + Title = title, + Description = description, + Options = options ?? new AlertDialogOptions() + }; + + dialogs.Add(data); + OnChange?.Invoke(); + + return data.Completion; + } + + /// + /// Shows a confirm dialog. /// /// The dialog title. - /// The dialog description/message. - /// Optional customization options for button labels and variant. - /// True if the user clicked confirm, false if cancelled. - public Task Confirm(string title, string? description = null, ConfirmDialogOptions? options = null) + /// Optional dialog description or message. + /// Optional customization options. + /// + /// A task that resolves to a + /// indicating whether the user confirmed the action. + /// + public async Task ConfirmAsync( + string title, + string? description = null, + ConfirmDialogOptions? options = null) { var data = new ConfirmDialogData { @@ -37,34 +73,112 @@ public Task Confirm(string title, string? description = null, ConfirmDialo dialogs.Add(data); OnChange?.Invoke(); - return data.Tcs.Task; + var result = await data.Completion; + return new ConfirmDialogResult(result); + } + + + /// + /// Shows a confirm dialog. + /// + /// The dialog title. + /// Optional dialog description or message. + /// Optional customization options. + /// + /// A task that resolves to if the user confirmed, otherwise. + /// + [Obsolete("Use ConfirmAsync instead, which returns a ConfirmDialogResult with richer information.")] + public async Task Confirm( + string title, + string? description = null, + ConfirmDialogOptions? options = null) + { + var result = await ConfirmAsync(title, description, options); + return result.Confirmed; + } + + /// + /// Shows a prompt dialog that collects text input from the user. + /// + /// The dialog title. + /// Optional dialog description or message. + /// Optional customization options. + /// + /// A task that resolves to a + /// containing the entered value when confirmed. + /// + public async Task PromptAsync( + string title, + string? description = null, + PromptDialogOptions? options = null) + { + var data = new PromptDialogData + { + Title = title, + Description = description, + Options = options ?? new PromptDialogOptions() + }; + + dialogs.Add(data); + OnChange?.Invoke(); + + var result = await data.Completion; + return new PromptDialogResult(result); } /// - /// Resolves a dialog with the given result and removes it from the list. - /// Called by DialogProvider when the user clicks confirm or cancel. + /// Opens a custom component inside a dialog. /// - /// The dialog ID to resolve. - /// True for confirm, false for cancel. - internal void Resolve(string id, bool result) + public Task OpenAsync( + Dictionary parameters, + DialogOpenOptions? options = null) + where TComponent : IComponent + { + ComponentDialogData data = null!; + + data = new ComponentDialogData + { + Title = options?.Title ?? string.Empty, + ComponentType = typeof(TComponent), + Parameters = parameters, + Options = options ?? new DialogOpenOptions(), + CloseCallback = result => + { + Resolve(data.Id, result); + return Task.CompletedTask; + } + }; + + dialogs.Add(data); + OnChange?.Invoke(); + + return data.Completion; + } + + /// + /// Resolves a dialog with the specified result and removes it from the collection. + /// + internal void Resolve(string id, DialogResult result) { var dialog = dialogs.FirstOrDefault(d => d.Id == id); - if (dialog != null) + if (dialog is null) { - dialogs.Remove(dialog); - dialog.Tcs.TrySetResult(result); - OnChange?.Invoke(); + return; } + + dialogs.Remove(dialog); + dialog.SetResult(result); + OnChange?.Invoke(); } /// - /// Cancels all pending dialogs, resolving each with false. + /// Cancels all pending dialogs. /// internal void CancelAll() { foreach (var dialog in dialogs.ToList()) { - dialog.Tcs.TrySetResult(false); + dialog.SetResult(DialogResult.Cancel()); } dialogs.Clear(); diff --git a/src/BlazorBlueprint.Components/Components/Dialog/DialogSize.cs b/src/BlazorBlueprint.Components/Components/Dialog/DialogSize.cs new file mode 100644 index 00000000..cfdcaf1a --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/DialogSize.cs @@ -0,0 +1,32 @@ +namespace BlazorBlueprint.Components; + +/// +/// Preset width options for dialogs opened via . +/// +public enum DialogSize +{ + /// + /// Small width dialog. + /// + Small, + + /// + /// Default medium width dialog. + /// + Default, + + /// + /// Large width dialog. + /// + Large, + + /// + /// Extra large width dialog. + /// + ExtraLarge, + + /// + /// Full width dialog. + /// + Full +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/IDialogReference.cs b/src/BlazorBlueprint.Components/Components/Dialog/IDialogReference.cs new file mode 100644 index 00000000..4bac3aeb --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/IDialogReference.cs @@ -0,0 +1,21 @@ +namespace BlazorBlueprint.Components; + +/// +/// Provides control over a custom component dialog instance. +/// This interface is cascaded to components opened via +/// . +/// +public interface IDialogReference +{ + /// + /// Closes the dialog with a specified result. + /// + /// The result returned to the caller. + public Task CloseAsync(DialogResult result); + + /// + /// Cancels the dialog. + /// Equivalent to calling with . + /// + public Task CancelAsync(); +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/Internals/AlertDialog.razor b/src/BlazorBlueprint.Components/Components/Dialog/Internals/AlertDialog.razor new file mode 100644 index 00000000..def5c4e5 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/Internals/AlertDialog.razor @@ -0,0 +1,20 @@ +@using BlazorBlueprint.Components.Components.Dialog.Internals.Shared +@inject DialogService DialogService + + + + +
+ + @Dialog.Options.ButtonText + +
+ +@code { + [Parameter, EditorRequired] + public AlertDialogData Dialog { get; set; } = default!; + + private void Acknowledge() + => DialogService.Resolve(Dialog.Id, DialogResult.Ok()); +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/Internals/ComponentDialog.razor b/src/BlazorBlueprint.Components/Components/Dialog/Internals/ComponentDialog.razor new file mode 100644 index 00000000..320beab7 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/Internals/ComponentDialog.razor @@ -0,0 +1,13 @@ +@using BlazorBlueprint.Components.Components.Dialog.Internals.Shared + + + + + + + +@code { + [Parameter, EditorRequired] + public ComponentDialogData Dialog { get; set; } = default!; +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/Internals/ConfirmDialog.razor b/src/BlazorBlueprint.Components/Components/Dialog/Internals/ConfirmDialog.razor new file mode 100644 index 00000000..bae17ecf --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/Internals/ConfirmDialog.razor @@ -0,0 +1,29 @@ +@using BlazorBlueprint.Components.Components.Dialog.Internals.Shared +@inject DialogService DialogService + + + + +
+ + @Dialog.Options.CancelText + + + + @Dialog.Options.ConfirmText + +
+ +@code { + [Parameter, EditorRequired] + public ConfirmDialogData Dialog { get; set; } = default!; + + private void Cancel() + => DialogService.Resolve(Dialog.Id, DialogResult.Cancel()); + + private void Confirm() + => DialogService.Resolve(Dialog.Id, DialogResult.Ok(true)); +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/Internals/PromptDialog.razor b/src/BlazorBlueprint.Components/Components/Dialog/Internals/PromptDialog.razor new file mode 100644 index 00000000..fb5c6e11 --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/Internals/PromptDialog.razor @@ -0,0 +1,61 @@ +@using BlazorBlueprint.Components.Components.Dialog.Internals.Shared +@inject DialogService DialogService + + + + + + + + + + + + +
+ + @Dialog.Options.CancelText + + + + @Dialog.Options.ConfirmText + +
+ +@code { + [Parameter, EditorRequired] + public PromptDialogData Dialog { get; set; } = default!; + + private string? inputValue; + + private IReadOnlyList Errors + { + get + { + var errors = new List(); + + if (Dialog.Options.Required && string.IsNullOrWhiteSpace(inputValue)) + errors.Add("The value is required"); + + if (Dialog.Options.MaxLength > 0 && inputValue?.Length > Dialog.Options.MaxLength) + errors.Add($"Value must be at most {Dialog.Options.MaxLength} characters."); + + return errors; + } + } + + private bool IsInvalid => Errors.Count > 0; + + protected override void OnInitialized() + => inputValue = Dialog.Options.DefaultValue; + + private void Cancel() + => DialogService.Resolve(Dialog.Id, DialogResult.Cancel()); + + private void Submit() + => DialogService.Resolve(Dialog.Id, DialogResult.Ok(inputValue)); +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/Internals/Shared/DialogHeader.razor b/src/BlazorBlueprint.Components/Components/Dialog/Internals/Shared/DialogHeader.razor new file mode 100644 index 00000000..b38ea12e --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/Internals/Shared/DialogHeader.razor @@ -0,0 +1,17 @@ +
+

+ @Title +

+ + @if (!string.IsNullOrEmpty(Description)) + { +

+ @Description +

+ } +
+ +@code { + [Parameter] public string Title { get; set; } = string.Empty; + [Parameter] public string? Description { get; set; } +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/PromptDialogData.cs b/src/BlazorBlueprint.Components/Components/Dialog/PromptDialogData.cs new file mode 100644 index 00000000..955766be --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/PromptDialogData.cs @@ -0,0 +1,17 @@ +namespace BlazorBlueprint.Components; + +/// +/// Represents a pending prompt dialog that returns a string value. +/// +/// +/// A prompt dialog displays a text input field and allows the user +/// to submit a string value or cancel the dialog. +/// The submitted value is available via . +/// +public sealed class PromptDialogData : DialogData +{ + /// + /// Gets or sets the customization options for the prompt dialog. + /// + public PromptDialogOptions Options { get; set; } = new(); +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/PromptDialogOptions.cs b/src/BlazorBlueprint.Components/Components/Dialog/PromptDialogOptions.cs new file mode 100644 index 00000000..8abf2b5d --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/PromptDialogOptions.cs @@ -0,0 +1,47 @@ +namespace BlazorBlueprint.Components; + +/// +/// Provides configuration options for a prompt dialog shown via +/// . +/// +public class PromptDialogOptions : DialogOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// + /// Sets the default confirmation button text to "OK". + /// + public PromptDialogOptions() + { + ConfirmText = "OK"; + } + + /// + /// Gets or sets the initial value of the input field. + /// + public string? DefaultValue { get; set; } + + /// + /// Gets or sets the placeholder text displayed in the input field. + /// + public string? Placeholder { get; set; } + + /// + /// Gets or sets a value indicating whether the input field is required. + /// + /// + /// When true, the confirm button should be disabled + /// until a non-empty value is entered. + /// + public bool Required { get; set; } + + /// + /// Gets or sets the maximum allowed length of the input value. + /// + /// + /// When specified, the rendered input element should enforce + /// this value via the maxlength attribute. + /// + public int? MaxLength { get; set; } +} diff --git a/src/BlazorBlueprint.Components/Components/Dialog/PromptDialogResult.cs b/src/BlazorBlueprint.Components/Components/Dialog/PromptDialogResult.cs new file mode 100644 index 00000000..2d85af4f --- /dev/null +++ b/src/BlazorBlueprint.Components/Components/Dialog/PromptDialogResult.cs @@ -0,0 +1,29 @@ +namespace BlazorBlueprint.Components; + +/// +/// Represents the result of a prompt dialog interaction. +/// +/// +/// A prompt dialog allows the user to enter text input. +/// When confirmed, the entered value is exposed via . +/// +public sealed class PromptDialogResult : DialogResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The underlying dialog result. + internal PromptDialogResult(DialogResult result) + : base(result.Cancelled, result.Data) + { + } + + /// + /// Gets the value entered by the user. + /// + /// + /// The submitted string when the dialog was confirmed; + /// otherwise, . + /// + public string? Value => Cancelled ? null : GetData(); +} diff --git a/src/BlazorBlueprint.Components/Components/Input/BbInput.razor.cs b/src/BlazorBlueprint.Components/Components/Input/BbInput.razor.cs index 9292d714..3f6e1600 100644 --- a/src/BlazorBlueprint.Components/Components/Input/BbInput.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Input/BbInput.razor.cs @@ -79,8 +79,8 @@ public partial class BbInput : ComponentBase /// Gets or sets the callback invoked when the input value changes. ///
/// - /// When is (default), - /// fires only on blur or Enter. Use for per-keystroke updates. + /// When is (default), + /// fires only on blur or Enter. Use for per-keystroke updates. /// [Parameter] public EventCallback ValueChanged { get; set; } diff --git a/src/BlazorBlueprint.Components/Components/Textarea/BbTextarea.razor.cs b/src/BlazorBlueprint.Components/Components/Textarea/BbTextarea.razor.cs index 69c2568d..0c8cbf09 100644 --- a/src/BlazorBlueprint.Components/Components/Textarea/BbTextarea.razor.cs +++ b/src/BlazorBlueprint.Components/Components/Textarea/BbTextarea.razor.cs @@ -69,8 +69,8 @@ public partial class BbTextarea : ComponentBase /// Gets or sets the callback invoked when the textarea value changes. /// /// - /// When is (default), - /// fires only on blur or Enter. Use for per-keystroke updates. + /// When is (default), + /// fires only on blur or Enter. Use for per-keystroke updates. /// [Parameter] public EventCallback ValueChanged { get; set; } diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 664dbb17..3f040efe 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -2930,6 +2930,22 @@ - Type : AxisType - ParentChart : BbChartBase [CascadingParameter] +### AlertDialog (BlazorBlueprint.Components.Components.Dialog.Internals) + - Dialog : AlertDialogData [EditorRequired] + +### ComponentDialog (BlazorBlueprint.Components.Components.Dialog.Internals) + - Dialog : ComponentDialogData [EditorRequired] + +### ConfirmDialog (BlazorBlueprint.Components.Components.Dialog.Internals) + - Dialog : ConfirmDialogData [EditorRequired] + +### PromptDialog (BlazorBlueprint.Components.Components.Dialog.Internals) + - Dialog : PromptDialogData [EditorRequired] + +### DialogHeader (BlazorBlueprint.Components.Components.Dialog.Internals.Shared) + - Description : String + - Title : String + ### TreeItemNode`1 (BlazorBlueprint.Components) - AllowDrag : Func - Checkable : Boolean @@ -3066,6 +3082,13 @@ - ThisYear = 6 - Custom = 7 +### DialogSize (BlazorBlueprint.Components) + - Small = 0 + - Default = 1 + - Large = 2 + - ExtraLarge = 3 + - Full = 4 + ### DrawerDirection (BlazorBlueprint.Components) - Top = 0 - Bottom = 1 @@ -3471,6 +3494,10 @@ ## Interfaces +### IDialogReference (BlazorBlueprint.Components) + - CancelAsync() : Task + - CloseAsync(DialogResult result) : Task + ### IMenubarItem (BlazorBlueprint.Components) - IsDisabled : Boolean { get; } - FocusAsync() : Task