diff --git a/CHANGELOG.md b/CHANGELOG.md index af9c3179e..46279530f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). --- +## 2026-08-13 + +### Added + +- **Native `` rendering strategy for `BbDialog`** — A new opt-in rendering path that drives the browser's built-in `` element instead of the portal + Floating UI handshake. It is the first step of the phased plan in [#376](https://github.com/blazorblueprintui/ui/discussions/376), and it directly fixes [#479](https://github.com/blazorblueprintui/ui/issues/479): portaled overlays stop working when `BbPortalHost` and the interactive content that opens them live in different render-mode scopes (e.g. a static layout hosting an `InteractiveWebAssembly` island), because each scope gets its own scoped `PortalService`. A native `` lives in the browser's top layer regardless of DOM position and supplies its own focus trap, Escape handling and `::backdrop`, so `BbDialogPortal` renders inline and no shared scoped service or portal host is needed at all — the dialog simply works across render-mode boundaries. It is additive and non-breaking: the default remains the existing JS path. + - Choose per-component with `BbDialog RenderingStrategy="OverlayRenderingStrategy.Native"`, or opt the whole app in by passing a configure action to `AddBlazorBlueprintPrimitives(o => o.DefaultStrategy = OverlayRenderingStrategy.Native)`. + - A new scoped `INativeOverlayService` resolves the effective strategy and drives the ``; `native-dialog.js` detects `showModal()` support (cached) so an unsupported browser degrades safely rather than breaking. + - When native is active, `BbDialogContent` skips the JS focus-trap / scroll-lock / escape-key modules and `BbDialogOverlay` renders nothing (the `::backdrop` is the scrim). `CloseOnEscape`, `CloseOnOverlayClick` and `OnEscapeKeyDown` are honoured through native `cancel`/`close`/backdrop events. + - The styled components-layer `BbDialogContent` keeps the same fixed, centred presentation as the JS path (so the design is identical) while the native `` additionally enters the top layer via `showModal()`; `dialog`/`::backdrop` CSS provides the scrim and sizing resets. A `dialog[data-state]` reset lives in the low-priority `components` layer so the component's own Tailwind utilities (padding, max-width, border, background, shadow) win over it. The reset pins `border-color` to `var(--border)` — without it the UA/`currentColor` fallback renders a far-too-bright border on dark backgrounds. + - AlertDialog, Sheet and the positioned overlays (Popover, Tooltip, Select, etc.) still use the portal path and are the follow-on phases of #376. + - `native-dialog.js` avoids top-level `let`/`const`/`class` bindings: Blazor WebAssembly's dynamic `import()` can re-evaluate an ES module in a shared scope, and top-level lexical bindings then collide with "Identifier has already been declared" (which surfaced in WASM as the dialog rendering but never entering the top layer). It uses `function` declarations and `globalThis`-cached state instead, which survive that re-evaluation. + - The Dialog demo page gains two examples: the inline `RenderingStrategy="Native"` dialog, and a programmatic `DialogService.OpenAsync()` example whose content component closes via the cascaded `IDialogReference.CloseAsync(...)` (noting that `BbDialogClose` does not close a programmatic dialog — there is no `DialogContext` in the `OpenAsync` path). + +--- + ## 2026-08-07 ### Added diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/native-service.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/native-service.txt new file mode 100644 index 000000000..bded15264 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/native-service.txt @@ -0,0 +1,38 @@ +@* Open a dialog programmatically with DialogService.OpenAsync() *@ +@inject DialogService DialogService + +Open via DialogService + +@nativeServiceResult + +@code { + private string nativeServiceResult = "-"; + + private async Task OpenNativeService() + { + var result = await DialogService.OpenAsync( + new Dictionary + { + ["Message"] = "Opened with OpenAsync()" + }, + new DialogOpenOptions + { + Title = "Native-Style Dialog", + Size = DialogSize.Default + }); + + nativeServiceResult = result.Cancelled ? "Cancelled" : "Closed"; + } + + // Content component. Note: BbDialogClose does NOT close a programmatic + // dialog (there is no DialogContext here) — close via IDialogReference. + public sealed class NativeDialogBodyComponent : ComponentBase + { + [Parameter] public string Message { get; set; } = ""; + + [CascadingParameter] + public IDialogReference DialogRef { get; set; } = default!; + + private Task Close() => DialogRef.CloseAsync(DialogResult.Ok()); + } +} diff --git a/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/native.txt b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/native.txt new file mode 100644 index 000000000..f71a6d8c7 --- /dev/null +++ b/demos/BlazorBlueprint.Demo.Shared/CodeExamples/Components/Dialog/native.txt @@ -0,0 +1,17 @@ + + + Open Native Dialog + + + + + Native dialog + + This dialog is a native <dialog> element shown with showModal(). + + + + Close + + + diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor index f394d137e..8e47cf582 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor @@ -1,4 +1,5 @@ @page "/components/dialog" +@using BlazorBlueprint.Primitives.Services @inject DialogService DialogService Dialog Component - Blazor Blueprint @@ -40,6 +41,67 @@ +
+

Native Dialog

+

+ The same dialog rendered through the browser's built-in <dialog> + element via RenderingStrategy="Native". It lives in the + top layer and uses the browser's native focus trap, Escape handling and backdrop, so it needs no + BbPortalHost and works across Blazor render-mode + boundaries (e.g. InteractiveWebAssembly). +

+ + + + Open Native Dialog + + + + + Native dialog + + This dialog is a native <dialog> element + shown with showModal(). Press Escape or click the + backdrop to dismiss. + + + + + Close + + + + + + +
+ +
+

Native Dialog via DialogService

+

+ Open the same dialog programmatically with + DialogService.OpenAsync<T>(). The content is a + self-contained component rendered by the dialog provider. +

+
+

+ Note: BbDialogClose does not close a + programmatic dialog — it drives a DialogContext, which the + OpenAsync path does not provide. Close a programmatic dialog + from its content by calling the cascaded + IDialogReference.CloseAsync(...) instead, exactly as the + custom component below does. +

+
+ +
+ Open via DialogService + @nativeServiceResult +
+ + +
+

Dialog with Footer

@@ -661,6 +723,12 @@ Whether the dialog is modal. Modal dialogs trap focus and lock scroll. + + Overrides how the dialog renders. Native uses the browser's built-in + <dialog> element (top layer, native focus trap/Escape/backdrop), which needs no + portal host and works across render-mode boundaries (e.g. InteractiveWebAssembly). When null, the + global default from AddBlazorBlueprintPrimitives(configureOverlays) applies. + Event callback invoked when the dialog open state changes. @@ -850,6 +918,7 @@ private string alertStatus = "Idle"; private string promptResult = "-"; private string customResult = "-"; + private string nativeServiceResult = "-"; // Form in Dialog private string? formFirstName; @@ -976,6 +1045,42 @@ } } + private async Task HandleOpenNativeService() + { + var result = await DialogService.OpenAsync( + new Dictionary + { + ["Message"] = "This dialog was opened with DialogService.OpenAsync(). Its Close button calls IDialogReference.CloseAsync() — BbDialogClose is not usable in the programmatic path." + }, + new DialogOpenOptions + { + Title = "Native-Style Dialog", + Size = DialogSize.Default + }); + + nativeServiceResult = result.Cancelled ? "Cancelled" : "Closed"; + } + + public sealed class NativeDialogBodyComponent : ComponentBase + { + [Parameter] public string Message { get; set; } = ""; + + [CascadingParameter] + public IDialogReference DialogRef { get; set; } = default!; + + protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder) + { +

+

@Message

+
+ Close +
+
+ } + + private Task Close() => DialogRef.CloseAsync(DialogResult.Ok()); + } + public sealed class EditUserDemoComponent : ComponentBase { [Parameter] public int UserId { get; set; } diff --git a/demos/BlazorBlueprint.Demo.Shared/Pages/Guides/RenderModesGuide.razor b/demos/BlazorBlueprint.Demo.Shared/Pages/Guides/RenderModesGuide.razor index 42026a269..8bb991ac6 100644 --- a/demos/BlazorBlueprint.Demo.Shared/Pages/Guides/RenderModesGuide.razor +++ b/demos/BlazorBlueprint.Demo.Shared/Pages/Guides/RenderModesGuide.razor @@ -162,6 +162,33 @@

+ +
+

Overlays across render-mode boundaries (Dialog → native)

+

+ The default portal-based overlays need BbPortalHost + to live in the same render-mode scope as the interactive content that opens them. + If a BbDialog is opened from an + InteractiveWebAssembly island but the host is + in a static layout, they use different scoped PortalService + instances — you get “No <PortalHost /> detected” and a + “timed out waiting for PortalHost to render” warning, and the dialog never appears. +

+

+ For Dialog there is a second option that sidesteps the scoped-service handshake entirely: + render it as a native <dialog> element. Set + RenderingStrategy="OverlayRenderingStrategy.Native" + on BbDialog (or set the global default via + AddBlazorBlueprintPrimitives(o => o.DefaultStrategy = OverlayRenderingStrategy.Native)). + The browser shows the dialog in the top layer with native focus trapping, Escape handling and a + ::backdrop — no portal host, no shared + scoped service, so it works across InteractiveWebAssembly + islands and static layouts. It falls back to the JS path automatically if the browser lacks + showModal(). Other overlay components (Popover, + Tooltip, Select, Sheet, AlertDialog) still use the portal path for now. +

+
+

Troubleshooting checklist

diff --git a/src/BlazorBlueprint.Components/Components/Dialog/BbDialog.razor b/src/BlazorBlueprint.Components/Components/Dialog/BbDialog.razor index 9432402b6..8b9877da0 100644 --- a/src/BlazorBlueprint.Components/Components/Dialog/BbDialog.razor +++ b/src/BlazorBlueprint.Components/Components/Dialog/BbDialog.razor @@ -1,4 +1,5 @@ @namespace BlazorBlueprint.Components +@using BlazorBlueprint.Primitives.Services @* Styled Dialog component wrapper. @@ -9,7 +10,8 @@ OpenChanged="@OpenChanged" DefaultOpen="@DefaultOpen" OnOpenChange="@OnOpenChange" - Modal="@Modal"> + Modal="@Modal" + RenderingStrategy="@RenderingStrategy"> @ChildContent @@ -52,4 +54,15 @@ /// [Parameter] public bool Modal { get; set; } = true; + + /// + /// Overrides how this dialog renders. When null, the global default configured via + /// AddBlazorBlueprintPrimitives(configureOverlays) applies. + /// renders through the browser's built-in + /// <dialog> element, which works across Blazor render-mode boundaries + /// (e.g. InteractiveWebAssembly) without a portal host. + /// Defaults to null (use global default, which is ). + /// + [Parameter] + public OverlayRenderingStrategy? RenderingStrategy { get; set; } } diff --git a/src/BlazorBlueprint.Components/Components/Dialog/BbDialogContent.razor b/src/BlazorBlueprint.Components/Components/Dialog/BbDialogContent.razor index 2a6a3e534..0d614451f 100644 --- a/src/BlazorBlueprint.Components/Components/Dialog/BbDialogContent.razor +++ b/src/BlazorBlueprint.Components/Components/Dialog/BbDialogContent.razor @@ -1,19 +1,27 @@ @namespace BlazorBlueprint.Components @using BlazorBlueprint.Icons.Lucide.Components +@using BlazorBlueprint.Primitives.Services @inject IBbLocalizer Localizer +@inject INativeOverlayService NativeOverlayService @* Styled DialogContent component with shadcn/ui classes. Includes overlay, dialog box, and close button. + When the effective rendering strategy is Native, the overlay is omitted (the browser's + ::backdrop provides the scrim) and the dialog box is styled for the top layer. *@ - + @if (!_useNative) + { + + } @code { + [CascadingParameter(Name = "DialogRenderingStrategy")] + private OverlayRenderingStrategy? RenderingStrategy { get; set; } + /// /// The content to render inside the dialog. /// @@ -99,17 +110,28 @@ [Parameter] public EventCallback OnEscapeKeyDown { get; set; } + private bool _useNative; + + protected override void OnInitialized() + { + _useNative = NativeOverlayService.ResolveStrategy(RenderingStrategy) == OverlayRenderingStrategy.Native; + } + /// /// Gets the computed CSS classes for the dialog content. /// Uses the cn() utility for intelligent class merging and Tailwind conflict resolution. + /// Both rendering strategies use the same fixed, centred presentation — the native path + /// additionally enters the top layer via showModal() for its backdrop/focus-trap/inertness, + /// so no separate positioning branch is needed. /// - private string GetClassNames() => ClassNames.cn( - "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]", - "gap-4 border bg-background p-6 shadow-lg duration-200", - "data-[state=open]:animate-in data-[state=closed]:animate-out", - "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", - "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", - "sm:rounded-lg", - Class - ); + private string GetClassNames() => + ClassNames.cn( + "fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]", + "gap-4 border bg-background p-6 shadow-lg duration-200", + "data-[state=open]:animate-in data-[state=closed]:animate-out", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + "sm:rounded-lg", + Class + ); } diff --git a/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css b/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css index 7ef64e183..e27c48b4e 100644 --- a/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css +++ b/src/BlazorBlueprint.Components/wwwroot/css/blazorblueprint-input.css @@ -871,6 +871,36 @@ } /* end @layer bb */ +/* Native rendering (OverlayRenderingStrategy.Native). + Deliberately OUTSIDE @layer bb (and in @layer components, below utilities) so the + component's own Tailwind classes (p-6, max-w-lg, border, bg-background, shadow-lg) + override these reset defaults. showModal() puts the dialog in the top layer (native + focus trap, inert background and ::backdrop scrim); positioning is the same fixed, + centred set the JS path uses, so no margin/inset rules are needed here. */ +@layer components { + dialog[data-state] { + max-height: calc(100dvh - 2rem); + overflow: auto; + padding: 0; + color: var(--foreground); + background: var(--background); + /* Reset the UA's opaque default border but keep the theme border colour so the + Tailwind `border` utility yields the same subtle border as the JS path (which + reads var(--border)); without this the border falls back to currentColor and is + far too bright on dark backgrounds. */ + border: 0 solid var(--border); + } + + dialog[data-state="open"]::backdrop { + background: rgb(0 0 0 / 0.8); + backdrop-filter: blur(2px); + } + + dialog[data-state="open"] { + outline: none; + } +} + /* Alert countdown bar animation. Keyframes are not subject to cascade layers, so left at top level. */ @keyframes bb-alert-countdown { diff --git a/src/BlazorBlueprint.Primitives/Extensions/ServiceCollectionExtensions.cs b/src/BlazorBlueprint.Primitives/Extensions/ServiceCollectionExtensions.cs index ffe0b1b3f..b072d82e2 100644 --- a/src/BlazorBlueprint.Primitives/Extensions/ServiceCollectionExtensions.cs +++ b/src/BlazorBlueprint.Primitives/Extensions/ServiceCollectionExtensions.cs @@ -12,9 +12,22 @@ public static class ServiceCollectionExtensions /// Adds all BlazorBlueprint.Primitives primitive services to the service collection. /// /// The service collection. + /// Optional action to configure overlay rendering options + /// (e.g. opt the whole app into native <dialog> rendering). /// The service collection for chaining. - public static IServiceCollection AddBlazorBlueprintPrimitives(this IServiceCollection services) + public static IServiceCollection AddBlazorBlueprintPrimitives( + this IServiceCollection services, + Action? configureOverlays = null) { + // Overlay rendering options (global default strategy). Registered as singleton so the + // resolved default is consistent across all render-mode scopes. + var overlayOptions = new OverlayRenderingOptions(); + configureOverlays?.Invoke(overlayOptions); + services.AddSingleton(overlayOptions); + + // Native overlay service (capability detection + native driving). + services.AddScoped(); + // Register PortalService as scoped for user isolation in Blazor Server // Each user session gets its own portal registry services.AddScoped(); diff --git a/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialog.razor b/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialog.razor index 54979da06..34c717d8d 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialog.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialog.razor @@ -1,8 +1,11 @@ @namespace BlazorBlueprint.Primitives.Dialog +@using BlazorBlueprint.Primitives.Services @* Dialog primitive root component - headless, unstyled behavior only *@ - @ChildContent + + @ChildContent + @code { @@ -16,6 +19,17 @@ [Parameter] public RenderFragment? ChildContent { get; set; } + /// + /// Overrides how this dialog renders. When null, the global default configured via + /// AddBlazorBlueprintPrimitives(configureOverlays) applies. + /// renders through the browser's built-in + /// <dialog> element (top layer, native focus trap, Escape and backdrop), + /// which works across Blazor render-mode boundaries and needs no portal host. + /// Defaults to null (use global default, which is ). + /// + [Parameter] + public OverlayRenderingStrategy? RenderingStrategy { get; set; } + /// /// Controls whether the dialog is open (controlled mode). /// When null, the dialog manages its own state (uncontrolled mode). diff --git a/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialogContent.razor b/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialogContent.razor index 926c6f3c9..947fa0da8 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialogContent.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialogContent.razor @@ -1,15 +1,37 @@ @namespace BlazorBlueprint.Primitives.Dialog @using System.ComponentModel +@using Microsoft.Extensions.Logging +@using BlazorBlueprint.Primitives.Services @inject IFocusManager FocusManager @inject IJSRuntime JSRuntime +@inject INativeOverlayService NativeOverlayService +@inject ILogger Logger @implements IAsyncDisposable @* DialogContent is the main content container for the dialog. - Handles focus trap, escape key, scroll lock, and ARIA attributes. + - JS strategy (default): renders a div, uses JS focus trap, escape key and scroll lock. + - Native strategy: renders a element driven with showModal()/close(). The + browser provides the top layer, focus trap, Escape handling and ::backdrop, so the JS + focus-trap / scroll-lock / escape modules are skipped. Native rendering needs no portal + host and works across Blazor render-mode boundaries (e.g. InteractiveWebAssembly). *@ -@if (Context.IsOpen) +@if (_useNative) +{ + @if (Context.IsOpen) + { + + @ChildContent + + } +} +else if (Context.IsOpen) {
/// The content to render inside the dialog. ///
@@ -47,6 +72,15 @@ [Parameter] public bool CloseOnEscape { get; set; } = true; + /// + /// Whether clicking the overlay (backdrop) should close the dialog. + /// Native strategy only: the browser's ::backdrop is not dismissible by default, + /// so this is honoured through a backdrop click listener. + /// Default is true. + /// + [Parameter] + public bool CloseOnOverlayClick { get; set; } = true; + /// /// Whether to trap focus within the dialog. /// Default is true for modal dialogs. @@ -76,6 +110,9 @@ private string _instanceId = Guid.NewGuid().ToString("N"); private bool _isInitialized = false; private bool _disposed; + private bool _useNative; + private bool _nativeInitialized = false; + private IAsyncDisposable? _nativeDialogSetup; protected override void OnInitialized() { @@ -86,11 +123,58 @@ "Ensure DialogContent is a child of a Dialog component."); } + _useNative = NativeOverlayService.ResolveStrategy(RenderingStrategy) == OverlayRenderingStrategy.Native; + // Subscribe to context changes Context.OnStateChanged += HandleContextStateChanged; } protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_useNative) + { + await HandleNativeLifecycleAsync(); + return; + } + + await HandleJsLifecycleAsync(); + } + + private async Task HandleNativeLifecycleAsync() + { + if (Context.IsOpen && !_nativeInitialized) + { + _nativeInitialized = true; + + try + { + // Diagnostic only — if native was requested but the browser lacks showModal(), + // the element still renders but cannot enter the top layer. + if (!await NativeOverlayService.IsDialogSupportedAsync()) + { + Logger.LogWarning( + "Dialog '{DialogId}' uses OverlayRenderingStrategy.Native but the browser does not support " + + ".showModal(); the dialog will not render as a modal.", Context.ContentId); + } + + _dotNetRef ??= DotNetObjectReference.Create(this); + _nativeDialogSetup = await NativeOverlayService.SetupDialogAsync(_contentRef, _dotNetRef); + + await NativeOverlayService.ShowDialogAsync(_contentRef); + await NativeOverlayService.FocusDialogAsync(_contentRef); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException or InvalidOperationException) + { + // Expected during circuit disconnect or prerendering. + } + } + else if (!Context.IsOpen && _nativeInitialized) + { + await CleanupNativeAsync(); + } + } + + private async Task HandleJsLifecycleAsync() { if (Context.IsOpen && !_isInitialized) { @@ -151,18 +235,25 @@ } else if (!Context.IsOpen && _isInitialized) { - await CleanupAsync(); + await CleanupJsAsync(); } } private void HandleContextStateChanged() { - if (!Context.IsOpen && _isInitialized) + if (!Context.IsOpen && (_isInitialized || _nativeInitialized)) { // Dialog closed, clean up on the Blazor sync context _ = InvokeAsync(async () => { - await CleanupAsync(); + if (_useNative) + { + await CleanupNativeAsync(); + } + else + { + await CleanupJsAsync(); + } StateHasChanged(); }); return; @@ -171,6 +262,56 @@ StateHasChanged(); } + // ---- Native dialog events (invoked from native-dialog.js) ---- + + [JSInvokable] + [EditorBrowsable(EditorBrowsableState.Never)] + public async Task JsOnNativeCancel() + { + if (_disposed) { return; } + + if (OnEscapeKeyDown.HasDelegate) + { + await OnEscapeKeyDown.InvokeAsync(new KeyboardEventArgs { Key = "Escape" }); + } + + if (CloseOnEscape) + { + Context.Close(); + } + } + + [JSInvokable] + [EditorBrowsable(EditorBrowsableState.Never)] + public Task JsOnNativeClose() + { + if (_disposed) { return Task.CompletedTask; } + + // Safety net: sync state if the dialog was closed natively. + if (Context.IsOpen) + { + Context.Close(); + } + + return Task.CompletedTask; + } + + [JSInvokable] + [EditorBrowsable(EditorBrowsableState.Never)] + public Task JsOnNativeBackdropClick() + { + if (_disposed) { return Task.CompletedTask; } + + if (CloseOnOverlayClick) + { + Context.Close(); + } + + return Task.CompletedTask; + } + + // ---- JS strategy (legacy) ---- + [JSInvokable] [EditorBrowsable(EditorBrowsableState.Never)] public async Task JsOnEscapeKey() @@ -190,7 +331,38 @@ } } - private async Task CleanupAsync() + private async Task CleanupNativeAsync() + { + if (_nativeDialogSetup != null) + { + try + { + await _nativeDialogSetup.DisposeAsync(); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) + { + // Cleanup may already be disposed or circuit disconnected. + } + _nativeDialogSetup = null; + } + + // Ensure the dialog is closed if it was left open (e.g. ForceMount kept it mounted). + if (Context.IsOpen == false) + { + try + { + await NativeOverlayService.CloseDialogAsync(_contentRef); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect. + } + } + + _nativeInitialized = false; + } + + private async Task CleanupJsAsync() { // Dispose focus trap if (_focusTrap != null) @@ -236,7 +408,14 @@ { _disposed = true; - await CleanupAsync(); + if (_useNative) + { + await CleanupNativeAsync(); + } + else + { + await CleanupJsAsync(); + } if (_portalModule != null) { diff --git a/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialogOverlay.razor b/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialogOverlay.razor index cd81df8a9..a2cab0461 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialogOverlay.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialogOverlay.razor @@ -1,11 +1,15 @@ @namespace BlazorBlueprint.Primitives.Dialog +@using BlazorBlueprint.Primitives.Services +@inject INativeOverlayService NativeOverlayService @* DialogOverlay provides a background overlay for modal dialogs. Can handle click-outside-to-close if configured. + In native strategy mode this renders nothing — the browser's ::backdrop + provides the scrim, and backdrop dismissal is handled by BbDialogContent. *@ -@if (Context.IsOpen) +@if (!_useNative && Context.IsOpen) {
/// The content to render within the overlay (typically empty for a simple backdrop). ///
@@ -44,6 +51,8 @@ [Parameter] public EventCallback OnClick { get; set; } + private bool _useNative; + protected override void OnInitialized() { if (Context == null) @@ -52,6 +61,8 @@ "DialogOverlay must be used within a Dialog component. " + "Ensure DialogOverlay is a child of a Dialog component."); } + + _useNative = NativeOverlayService.ResolveStrategy(RenderingStrategy) == OverlayRenderingStrategy.Native; } private async Task HandleClick(MouseEventArgs args) diff --git a/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialogPortal.razor b/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialogPortal.razor index a541a8eea..dcd27f4e8 100644 --- a/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialogPortal.razor +++ b/src/BlazorBlueprint.Primitives/Primitives/Dialog/BbDialogPortal.razor @@ -1,27 +1,37 @@ @namespace BlazorBlueprint.Primitives.Dialog +@using BlazorBlueprint.Primitives.Services @inject IPortalService PortalService +@inject INativeOverlayService NativeOverlayService @implements IDisposable @* DialogPortal renders its content at the document body level using PortalService. This ensures proper z-index stacking for overlays. When Container is set, renders inline instead (for contained scenarios). + When the effective rendering strategy is Native, renders inline too: the native + element lives in the browser's top layer regardless of DOM position, so no + portal relocation or shared scoped service is needed. This is what makes dialogs work + across Blazor render-mode boundaries (e.g. InteractiveWebAssembly). *@ -@if (!string.IsNullOrEmpty(Container)) +@if (!_useNative && string.IsNullOrEmpty(Container)) { - @if (Context.IsOpen || ForceMount) - { - - @ChildContent - - } + @* Default: render through the PortalService (JS strategy). *@ +} +else if (Context.IsOpen || ForceMount) +{ + + @ChildContent + } @code { [CascadingParameter] private DialogContext Context { get; set; } = null!; + [CascadingParameter(Name = "DialogRenderingStrategy")] + private OverlayRenderingStrategy? RenderingStrategy { get; set; } + /// /// The content to render in the portal (typically DialogOverlay and DialogContent). /// @@ -48,6 +58,7 @@ private string _portalId = string.Empty; private bool _isRegistered = false; + private bool _useNative; protected override void OnInitialized() { @@ -60,11 +71,15 @@ _portalId = $"{Context.Id}-portal"; + // Resolve the effective rendering strategy synchronously for this render. + _useNative = NativeOverlayService.ResolveStrategy(RenderingStrategy) == OverlayRenderingStrategy.Native; + // Subscribe to context changes to update portal content Context.OnStateChanged += HandleStateChanged; - // When Container is set, content renders inline — skip portal registration - if (string.IsNullOrEmpty(Container)) + // When Container is set or the native strategy is active, content renders inline — + // skip portal registration. + if (string.IsNullOrEmpty(Container) && !_useNative) { // Register initial portal if dialog is open or force mount if (Context.IsOpen || ForceMount) @@ -76,8 +91,8 @@ protected override void OnParametersSet() { - // When Container is set, content renders inline — skip portal registration - if (!string.IsNullOrEmpty(Container)) + // When Container is set or native strategy is active, content renders inline — skip portal registration + if (!string.IsNullOrEmpty(Container) || _useNative) { UnregisterPortal(); return; diff --git a/src/BlazorBlueprint.Primitives/Services/INativeOverlayService.cs b/src/BlazorBlueprint.Primitives/Services/INativeOverlayService.cs new file mode 100644 index 000000000..26f10f38b --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Services/INativeOverlayService.cs @@ -0,0 +1,50 @@ +using Microsoft.AspNetCore.Components; + +namespace BlazorBlueprint.Primitives.Services; + +/// +/// Resolves the effective for a component and drives +/// the browser's native overlay primitives (currently the <dialog> element). +/// +public interface INativeOverlayService +{ + /// + /// Gets whether the browser supports the native <dialog> element's + /// showModal(). Resolved once per scope and cached. Returns false when JS interop + /// is unavailable (e.g. during prerendering). Used for diagnostics when native is requested. + /// + Task IsDialogSupportedAsync(); + + /// + /// Resolves the strategy a component should render with, synchronously (safe to call during + /// render). A non-null (the component's own parameter) wins; + /// otherwise the global default applies. + /// + OverlayRenderingStrategy ResolveStrategy(OverlayRenderingStrategy? requested); + + /// + /// Opens a <dialog> element as a modal (top layer). + /// + Task ShowDialogAsync(ElementReference element); + + /// + /// Closes a <dialog> element. + /// + Task CloseDialogAsync(ElementReference element, string? returnValue = null); + + /// + /// Focuses a dialog's content after opening (falls back to first focusable element). + /// + Task FocusDialogAsync(ElementReference element); + + /// + /// Restores focus to the element that opened the dialog. + /// + Task FocusTriggerAsync(ElementReference element); + + /// + /// Wires native <dialog> lifecycle events (Escape/cancel, close, backdrop click) + /// to a .NET instance. The returned handle must be disposed to remove the listeners. + /// + Task SetupDialogAsync(ElementReference element, object dotNetRef); +} diff --git a/src/BlazorBlueprint.Primitives/Services/NativeOverlayService.cs b/src/BlazorBlueprint.Primitives/Services/NativeOverlayService.cs new file mode 100644 index 000000000..1a458a9d3 --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Services/NativeOverlayService.cs @@ -0,0 +1,182 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace BlazorBlueprint.Primitives.Services; + +/// +/// Default implementation of backed by the +/// native-dialog.js module. +/// +public class NativeOverlayService : INativeOverlayService, IAsyncDisposable +{ + private readonly IJSRuntime jsRuntime; + private readonly OverlayRenderingOptions options; + private readonly SemaphoreSlim moduleLock = new(1, 1); + private IJSObjectReference? module; + private bool? dialogSupported; + private bool disposed; + + public NativeOverlayService(IJSRuntime jsRuntime, OverlayRenderingOptions options) + { + this.jsRuntime = jsRuntime; + this.options = options; + } + + private async Task GetModuleAsync() + { + ObjectDisposedException.ThrowIf(disposed, nameof(NativeOverlayService)); + + await moduleLock.WaitAsync(); + try + { + if (module == null) + { + module = await jsRuntime.InvokeAsync( + "import", "./_content/BlazorBlueprint.Primitives/js/primitives/native-dialog.js"); + } + return module; + } + finally + { + moduleLock.Release(); + } + } + + /// + public async Task IsDialogSupportedAsync() + { + if (dialogSupported.HasValue) + { + return dialogSupported.Value; + } + + try + { + var objectReference = await GetModuleAsync(); + dialogSupported = await objectReference.InvokeAsync("supportsNativeDialog"); + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException or InvalidOperationException) + { + // JS interop unavailable (prerendering / disconnect). Assume unsupported for now. + dialogSupported = false; + } + + return dialogSupported.Value; + } + + /// + public OverlayRenderingStrategy ResolveStrategy(OverlayRenderingStrategy? requested) + => requested ?? options.DefaultStrategy; + + /// + public async Task ShowDialogAsync(ElementReference element) + { + try + { + var objectReference = await GetModuleAsync(); + await objectReference.InvokeVoidAsync("showModal", element); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException or InvalidOperationException) + { + // Expected during prerendering / disconnect. + } + } + + /// + public async Task CloseDialogAsync(ElementReference element, string? returnValue = null) + { + try + { + var objectReference = await GetModuleAsync(); + await objectReference.InvokeVoidAsync("closeDialog", element, returnValue); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException or InvalidOperationException) + { + // Expected during prerendering / disconnect. + } + } + + /// + public async Task FocusDialogAsync(ElementReference element) + { + try + { + var objectReference = await GetModuleAsync(); + await objectReference.InvokeVoidAsync("focusDialog", element); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException or InvalidOperationException) + { + // Expected during prerendering / disconnect. + } + } + + /// + public async Task FocusTriggerAsync(ElementReference element) + { + try + { + var objectReference = await GetModuleAsync(); + await objectReference.InvokeVoidAsync("focusElement", element); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException or InvalidOperationException) + { + // Expected during prerendering / disconnect. + } + } + + /// + public async Task SetupDialogAsync(ElementReference element, object dotNetRef) + { + var objectReference = await GetModuleAsync(); + var cleanup = await objectReference.InvokeAsync("setupDialog", element, dotNetRef); + return new NativeDialogHandle(cleanup); + } + + private sealed class NativeDialogHandle : IAsyncDisposable + { + private readonly IJSObjectReference cleanup; + + public NativeDialogHandle(IJSObjectReference cleanup) + { + this.cleanup = cleanup; + } + + public async ValueTask DisposeAsync() + { + try + { + await cleanup.InvokeVoidAsync("dispose"); + await cleanup.DisposeAsync(); + } + catch (Exception ex) when (ex is JSDisconnectedException or JSException or TaskCanceledException or ObjectDisposedException) + { + // Cleanup may already be disposed or circuit disconnected. + } + } + } + + public async ValueTask DisposeAsync() + { + if (disposed) + { + return; + } + + GC.SuppressFinalize(this); + disposed = true; + + if (module != null) + { + try + { + await module.DisposeAsync(); + } + catch (Exception ex) when (ex is JSDisconnectedException or TaskCanceledException or ObjectDisposedException) + { + // Expected during circuit disconnect. + } + } + + moduleLock.Dispose(); + } +} diff --git a/src/BlazorBlueprint.Primitives/Services/OverlayRenderingOptions.cs b/src/BlazorBlueprint.Primitives/Services/OverlayRenderingOptions.cs new file mode 100644 index 000000000..a5073c0bd --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Services/OverlayRenderingOptions.cs @@ -0,0 +1,16 @@ +namespace BlazorBlueprint.Primitives.Services; + +/// +/// Global options controlling how BlazorBlueprint overlays render by default. +/// Supplied via AddBlazorBlueprintPrimitives(configure) and read at run time. +/// +public class OverlayRenderingOptions +{ + /// + /// The default used by overlay components when + /// they do not specify one themselves. Defaults to , + /// preserving existing behaviour. Set to to opt + /// the whole app into native rendering (with automatic fallback when a browser lacks support). + /// + public OverlayRenderingStrategy DefaultStrategy { get; set; } = OverlayRenderingStrategy.JavaScript; +} diff --git a/src/BlazorBlueprint.Primitives/Services/OverlayRenderingStrategy.cs b/src/BlazorBlueprint.Primitives/Services/OverlayRenderingStrategy.cs new file mode 100644 index 000000000..90f89b0da --- /dev/null +++ b/src/BlazorBlueprint.Primitives/Services/OverlayRenderingStrategy.cs @@ -0,0 +1,25 @@ +namespace BlazorBlueprint.Primitives.Services; + +/// +/// Selects how portal-based overlay components (Dialog, and in future AlertDialog/Sheet, +/// Popover, Tooltip, etc.) render and are positioned. +/// +public enum OverlayRenderingStrategy +{ + /// + /// Render through the Blazor portal system (scoped + + /// Floating UI positioning over JS interop). This is the original behaviour and the + /// default. It requires <BbPortalHost /> to live in the same render-mode + /// scope as the interactive content that opens overlays. + /// + JavaScript = 0, + + /// + /// Use the browser's native primitives (e.g. <dialog> / popover) + /// which render in the top layer without a portal handshake or a shared scoped service. + /// This makes overlays work across Blazor render-mode boundaries (e.g. + /// InteractiveWebAssembly) and removes the JS focus-trap / scroll-lock / + /// escape-key machinery for the components that opt in. + /// + Native = 1 +} diff --git a/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/native-dialog.js b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/native-dialog.js new file mode 100644 index 000000000..3fa03117b --- /dev/null +++ b/src/BlazorBlueprint.Primitives/wwwroot/js/primitives/native-dialog.js @@ -0,0 +1,130 @@ +// Native rendering helper for Blazor components. +// Drives the browser's built-in element (top layer, focus trap, Escape, +// ::backdrop) instead of the JS focus-trap/scroll-lock/portal handshake. This +// removes the scoped-service + render-handshake dependency that breaks portaled +// overlays across Blazor render-mode boundaries (e.g. InteractiveWebAssembly). +// +// NOTE: this module deliberately uses only function declarations and globalThis +// state — no top-level `let`/`const`/`class`. Blazor WebAssembly's dynamic +// `import()` can re-evaluate a module in a shared scope, and top-level lexical +// bindings then collide with "Identifier has already been declared". Function +// declarations and globalThis assignments survive that re-evaluation safely. + +/** + * Detects whether the browser supports the native element with showModal(). + * Baseline: Chrome 37+, Firefox 98+, Safari 15.4+. Cached after the first call. + * @returns {boolean} + */ +export function supportsNativeDialog() { + if (globalThis.__bbSupportsNativeDialog !== undefined) { + return globalThis.__bbSupportsNativeDialog; + } + globalThis.__bbSupportsNativeDialog = typeof HTMLDialogElement !== 'undefined' + && typeof HTMLDialogElement.prototype.showModal === 'function'; + return globalThis.__bbSupportsNativeDialog; +} + +/** + * Opens a element as a modal (top layer). No-op if already open. + * @param {HTMLDialogElement} element + */ +export function showModal(element) { + if (!element) { + return; + } + if (supportsNativeDialog() && !element.open) { + element.showModal(); + } +} + +/** + * Closes a element. No-op if not open. + * @param {HTMLDialogElement} element + * @param {string|null} [returnValue] + */ +export function closeDialog(element, returnValue) { + if (!element) { + return; + } + if (supportsNativeDialog() && element.open) { + element.close(returnValue ?? null); + } +} + +/** + * Focuses the dialog itself (the browser's native focus trap already confines + * Tab within a modal dialog). Falls back to the first focusable element. + * @param {HTMLDialogElement} element + */ +export function focusDialog(element) { + if (!element) { + return; + } + if (document.activeElement === element) { + return; + } + element.focus(); + if (document.activeElement !== element) { + const focusable = element.querySelector( + 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), ' + + 'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + ); + if (focusable) { + focusable.focus(); + } + } +} + +/** + * Focuses the element that opened the dialog, restoring the trigger's focus. + * @param {HTMLElement} element + */ +export function focusElement(element) { + if (element && typeof element.focus === 'function') { + element.focus(); + } +} + +// ============================================================================ +// Native event wiring +// Blazor cannot reliably distinguish a backdrop click from a content click, and +// Escape (cancel) must be preventable so the component can honour CloseOnEscape. +// These listeners forward the native events to .NET, which owns the close logic. +// ============================================================================ + +/** + * Wires native dialog lifecycle events to a .NET instance. + * Expects the .NET object to expose JSInvokable methods: + * JsOnNativeCancel() - Escape pressed (cancel is prevented here) + * JsOnNativeClose() - dialog closed (safety net) + * JsOnNativeBackdropClick() - click landed on the dialog itself (backdrop) + * @param {HTMLDialogElement} dialog + * @param {object} dotNetRef - a DotNetObjectReference + * @returns {{ dispose: () => void }} + */ +export function setupDialog(dialog, dotNetRef) { + const onCancel = (e) => { + e.preventDefault(); + dotNetRef.invokeMethodAsync('JsOnNativeCancel'); + }; + const onClose = () => { + dotNetRef.invokeMethodAsync('JsOnNativeClose'); + }; + const onClick = (e) => { + if (e.target === dialog) { + dotNetRef.invokeMethodAsync('JsOnNativeBackdropClick'); + } + }; + + dialog.addEventListener('cancel', onCancel); + dialog.addEventListener('close', onClose); + dialog.addEventListener('click', onClick); + + return { + dispose: () => { + dialog.removeEventListener('cancel', onCancel); + dialog.removeEventListener('close', onClose); + dialog.removeEventListener('click', onClick); + } + }; +} diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt index 548b66c89..d8d671fed 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/ComponentsApiSurfaceTests.ComponentsApiSurfaceMatchesBaseline.verified.txt @@ -1158,6 +1158,7 @@ - OnOpenChange : EventCallback - Open : Boolean? - OpenChanged : EventCallback + - RenderingStrategy : OverlayRenderingStrategy? ### BbDialogClose (BlazorBlueprint.Components) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] @@ -1177,6 +1178,7 @@ - OnEscapeKeyDown : EventCallback - ShowClose : Boolean - TrapFocus : Boolean + - RenderingStrategy : OverlayRenderingStrategy? [CascadingParameter] ### BbDialogDescription (BlazorBlueprint.Components) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] diff --git a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt index baaff66a7..7b511cf1d 100644 --- a/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt +++ b/tests/BlazorBlueprint.Tests/ApiSurface/PrimitivesApiSurfaceTests.PrimitivesApiSurfaceMatchesBaseline.verified.txt @@ -227,6 +227,7 @@ - OnOpenChange : EventCallback - Open : Boolean? - OpenChanged : EventCallback + - RenderingStrategy : OverlayRenderingStrategy? ### BbDialogClose (BlazorBlueprint.Primitives.Dialog) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] @@ -240,10 +241,12 @@ - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] - ChildContent : RenderFragment - CloseOnEscape : Boolean + - CloseOnOverlayClick : Boolean - LockScroll : Boolean - OnEscapeKeyDown : EventCallback - TrapFocus : Boolean - Context : DialogContext [CascadingParameter] + - RenderingStrategy : OverlayRenderingStrategy? [CascadingParameter] ### BbDialogDescription (BlazorBlueprint.Primitives.Dialog) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] @@ -256,12 +259,14 @@ - CloseOnClick : Boolean - OnClick : EventCallback - Context : DialogContext [CascadingParameter] + - RenderingStrategy : OverlayRenderingStrategy? [CascadingParameter] ### BbDialogPortal (BlazorBlueprint.Primitives.Dialog) - ChildContent : RenderFragment - Container : String - ForceMount : Boolean - Context : DialogContext [CascadingParameter] + - RenderingStrategy : OverlayRenderingStrategy? [CascadingParameter] ### BbDialogTitle (BlazorBlueprint.Primitives.Dialog) - AdditionalAttributes : Dictionary [CaptureUnmatchedValues] @@ -1025,6 +1030,10 @@ - Horizontal = 0 - Vertical = 1 +### OverlayRenderingStrategy (BlazorBlueprint.Primitives.Services) + - JavaScript = 0 + - Native = 1 + ### SheetSide (BlazorBlueprint.Primitives) - Top = 0 - Right = 1 @@ -1159,6 +1168,15 @@ - Suspend() : Void - Unregister(String id) : Void +### INativeOverlayService (BlazorBlueprint.Primitives.Services) + - CloseDialogAsync(ElementReference element, String returnValue) : Task + - FocusDialogAsync(ElementReference element) : Task + - FocusTriggerAsync(ElementReference element) : Task + - IsDialogSupportedAsync() : Task + - ResolveStrategy(OverlayRenderingStrategy? requested) : OverlayRenderingStrategy + - SetupDialogAsync(ElementReference element, Object dotNetRef) : Task + - ShowDialogAsync(ElementReference element) : Task + ### IPortalService (BlazorBlueprint.Primitives.Services) - HasHost : Boolean { get; } - GetPortals(PortalCategory category) : IReadOnlyList> diff --git a/tests/BlazorBlueprint.Tests/Services/NativeOverlayServiceTests.cs b/tests/BlazorBlueprint.Tests/Services/NativeOverlayServiceTests.cs new file mode 100644 index 000000000..90e50093e --- /dev/null +++ b/tests/BlazorBlueprint.Tests/Services/NativeOverlayServiceTests.cs @@ -0,0 +1,53 @@ +using BlazorBlueprint.Primitives.Services; +using Microsoft.JSInterop; +using Xunit; + +namespace BlazorBlueprint.Tests.Services; + +public class NativeOverlayServiceTests +{ + private static NativeOverlayService CreateService(OverlayRenderingStrategy globalDefault) + { + var options = new OverlayRenderingOptions { DefaultStrategy = globalDefault }; + return new NativeOverlayService(new StubJsRuntime(), options); + } + + [Fact] + public void RequestedStrategyOverridesGlobalDefault() + { + var service = CreateService(globalDefault: OverlayRenderingStrategy.JavaScript); + + var resolved = service.ResolveStrategy(OverlayRenderingStrategy.Native); + + Assert.Equal(OverlayRenderingStrategy.Native, resolved); + } + + [Fact] + public void NullUsesGlobalDefaultNative() + { + var service = CreateService(globalDefault: OverlayRenderingStrategy.Native); + + var resolved = service.ResolveStrategy(null); + + Assert.Equal(OverlayRenderingStrategy.Native, resolved); + } + + [Fact] + public void NullUsesGlobalDefaultJavaScript() + { + var service = CreateService(globalDefault: OverlayRenderingStrategy.JavaScript); + + var resolved = service.ResolveStrategy(null); + + Assert.Equal(OverlayRenderingStrategy.JavaScript, resolved); + } + + private sealed class StubJsRuntime : IJSRuntime + { + public ValueTask InvokeAsync(string identifier, object?[]? args) => + throw new JSDisconnectedException("stub"); + + public ValueTask InvokeAsync(string identifier, CancellationToken cancellationToken, object?[]? args) => + throw new JSDisconnectedException("stub"); + } +} diff --git a/tests/BlazorBlueprint.Tests/Services/ServiceCollectionExtensionsTests.cs b/tests/BlazorBlueprint.Tests/Services/ServiceCollectionExtensionsTests.cs new file mode 100644 index 000000000..3a08cef94 --- /dev/null +++ b/tests/BlazorBlueprint.Tests/Services/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,42 @@ +using BlazorBlueprint.Primitives.Extensions; +using BlazorBlueprint.Primitives.Services; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace BlazorBlueprint.Tests.Services; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void RegistersNativeOverlayService() + { + var services = new ServiceCollection(); + + services.AddBlazorBlueprintPrimitives(); + + Assert.Contains(services, d => d.ServiceType == typeof(INativeOverlayService)); + Assert.Contains(services, d => d.Lifetime == ServiceLifetime.Scoped && d.ServiceType == typeof(INativeOverlayService)); + } + + [Fact] + public void RegistersOverlayRenderingOptionsAsSingleton() + { + var services = new ServiceCollection(); + + services.AddBlazorBlueprintPrimitives(); + + Assert.Contains(services, d => d.ServiceType == typeof(OverlayRenderingOptions) && d.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void ConfigureOverlaysSetsGlobalDefault() + { + var services = new ServiceCollection(); + + services.AddBlazorBlueprintPrimitives(o => o.DefaultStrategy = OverlayRenderingStrategy.Native); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService(); + Assert.Equal(OverlayRenderingStrategy.Native, options.DefaultStrategy); + } +}