Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,21 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

---

## 2026-08-13

### Added

- **Native `<dialog>` rendering strategy for `BbDialog`** — A new opt-in rendering path that drives the browser's built-in `<dialog>` 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 `<dialog>` 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 `<dialog>`; `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 `<dialog>` 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<T>()` 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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
@* Open a dialog programmatically with DialogService.OpenAsync<T>() *@
@inject DialogService DialogService

<BbButton OnClick="OpenNativeService">Open via DialogService</BbButton>

<span>@nativeServiceResult</span>

@code {
private string nativeServiceResult = "-";

private async Task OpenNativeService()
{
var result = await DialogService.OpenAsync<NativeDialogBodyComponent>(
new Dictionary<string, object?>
{
["Message"] = "Opened with OpenAsync<T>()"
},
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());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<BbDialog RenderingStrategy="OverlayRenderingStrategy.Native">
<BbDialogTrigger AsChild="false" class="...">
Open Native Dialog
</BbDialogTrigger>

<BbDialogContent>
<BbDialogHeader>
<BbDialogTitle>Native dialog</BbDialogTitle>
<BbDialogDescription>
This dialog is a native &lt;dialog&gt; element shown with showModal().
</BbDialogDescription>
</BbDialogHeader>
<BbDialogFooter>
<BbDialogClose AsChild="false" class="...">Close</BbDialogClose>
</BbDialogFooter>
</BbDialogContent>
</BbDialog>
105 changes: 105 additions & 0 deletions demos/BlazorBlueprint.Demo.Shared/Pages/Components/DialogDemo.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@page "/components/dialog"
@using BlazorBlueprint.Primitives.Services
@inject DialogService DialogService
<PageTitle>Dialog Component - Blazor Blueprint</PageTitle>

Expand Down Expand Up @@ -40,6 +41,67 @@
<CodeBlock Source="Components/Dialog/simple.txt" />
</div>

<div class="space-y-4">
<h2 class="text-2xl font-semibold">Native Dialog</h2>
<p class="text-sm text-muted-foreground">
The same dialog rendered through the browser's built-in <code class="text-xs bg-muted px-1 py-0.5 rounded">&lt;dialog&gt;</code>
element via <code class="text-xs bg-muted px-1 py-0.5 rounded">RenderingStrategy="Native"</code>. It lives in the
top layer and uses the browser's native focus trap, Escape handling and backdrop, so it needs no
<code class="text-xs bg-muted px-1 py-0.5 rounded">BbPortalHost</code> and works across Blazor render-mode
boundaries (e.g. <code class="text-xs bg-muted px-1 py-0.5 rounded">InteractiveWebAssembly</code>).
</p>

<BbDialog RenderingStrategy="OverlayRenderingStrategy.Native">
<BbDialogTrigger AsChild="false" class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2">
Open Native Dialog
</BbDialogTrigger>

<BbDialogContent>
<BbDialogHeader>
<BbDialogTitle>Native dialog</BbDialogTitle>
<BbDialogDescription>
This dialog is a native <code class="text-xs bg-muted px-1 py-0.5 rounded">&lt;dialog&gt;</code> element
shown with <code class="text-xs bg-muted px-1 py-0.5 rounded">showModal()</code>. Press Escape or click the
backdrop to dismiss.
</BbDialogDescription>
</BbDialogHeader>
<BbDialogFooter>
<BbDialogClose AsChild="false" class="inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2">
Close
</BbDialogClose>
</BbDialogFooter>
</BbDialogContent>
</BbDialog>

<CodeBlock Source="Components/Dialog/native.txt" />
</div>

<div class="space-y-4">
<h2 class="text-2xl font-semibold">Native Dialog via DialogService</h2>
<p class="text-sm text-muted-foreground">
Open the same dialog programmatically with
<code class="text-xs bg-muted px-1 py-0.5 rounded">DialogService.OpenAsync&lt;T&gt;()</code>. The content is a
self-contained component rendered by the dialog provider.
</p>
<div class="p-4 border-l-4 border-amber-500 bg-amber-500/5 rounded-r-lg">
<p class="text-sm text-muted-foreground">
<strong>Note:</strong> <code class="text-xs bg-muted px-1 py-0.5 rounded">BbDialogClose</code> does not close a
programmatic dialog — it drives a <code class="text-xs bg-muted px-1 py-0.5 rounded">DialogContext</code>, which the
<code class="text-xs bg-muted px-1 py-0.5 rounded">OpenAsync</code> path does not provide. Close a programmatic dialog
from its content by calling the cascaded
<code class="text-xs bg-muted px-1 py-0.5 rounded">IDialogReference.CloseAsync(...)</code> instead, exactly as the
custom component below does.
</p>
</div>

<div class="flex items-center gap-4">
<BbButton OnClick="HandleOpenNativeService">Open via DialogService</BbButton>
<span class="text-sm text-muted-foreground">@nativeServiceResult</span>
</div>

<CodeBlock Source="Components/Dialog/native-service.txt" />
</div>

<div class="space-y-4">
<h2 class="text-2xl font-semibold">Dialog with Footer</h2>
<p class="text-sm text-muted-foreground">
Expand Down Expand Up @@ -661,6 +723,12 @@
<ApiParameter Name="Modal" Type="bool" Default="true">
Whether the dialog is modal. Modal dialogs trap focus and lock scroll.
</ApiParameter>
<ApiParameter Name="RenderingStrategy" Type="OverlayRenderingStrategy?" Default="null">
Overrides how the dialog renders. <code>Native</code> uses the browser's built-in
<code>&lt;dialog&gt;</code> 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 <code>AddBlazorBlueprintPrimitives(configureOverlays)</code> applies.
</ApiParameter>
<ApiParameter Name="OnOpenChange" Type="EventCallback&lt;bool&gt;">
Event callback invoked when the dialog open state changes.
</ApiParameter>
Expand Down Expand Up @@ -850,6 +918,7 @@
private string alertStatus = "Idle";
private string promptResult = "-";
private string customResult = "-";
private string nativeServiceResult = "-";

// Form in Dialog
private string? formFirstName;
Expand Down Expand Up @@ -976,6 +1045,42 @@
}
}

private async Task HandleOpenNativeService()
{
var result = await DialogService.OpenAsync<NativeDialogBodyComponent>(
new Dictionary<string, object?>
{
["Message"] = "This dialog was opened with DialogService.OpenAsync<T>(). 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)
{
<div class="space-y-4">
<p class="text-sm text-muted-foreground">@Message</p>
<div class="flex justify-end gap-2">
<BbButton Variant="ButtonVariant.Outline" OnClick="Close">Close</BbButton>
</div>
</div>
}

private Task Close() => DialogRef.CloseAsync(DialogResult.Ok());
}

public sealed class EditUserDemoComponent : ComponentBase
{
[Parameter] public int UserId { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,33 @@
</p>
</section>

<!-- Native overlays across render modes -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold">Overlays across render-mode boundaries (Dialog &rarr; native)</h2>
<p class="text-muted-foreground leading-relaxed">
The default portal-based overlays need <code class="text-xs bg-muted px-1 py-0.5 rounded">BbPortalHost</code>
to live in the <strong>same</strong> render-mode scope as the interactive content that opens them.
If a <code class="text-xs bg-muted px-1 py-0.5 rounded">BbDialog</code> is opened from an
<code class="text-xs bg-muted px-1 py-0.5 rounded">InteractiveWebAssembly</code> island but the host is
in a static layout, they use different scoped <code class="text-xs bg-muted px-1 py-0.5 rounded">PortalService</code>
instances &mdash; you get &ldquo;No &lt;PortalHost /&gt; detected&rdquo; and a
&ldquo;timed out waiting for PortalHost to render&rdquo; warning, and the dialog never appears.
</p>
<p class="text-muted-foreground leading-relaxed">
For <strong>Dialog</strong> there is a second option that sidesteps the scoped-service handshake entirely:
render it as a native <code class="text-xs bg-muted px-1 py-0.5 rounded">&lt;dialog&gt;</code> element. Set
<code class="text-xs bg-muted px-1 py-0.5 rounded">RenderingStrategy="OverlayRenderingStrategy.Native"</code>
on <code class="text-xs bg-muted px-1 py-0.5 rounded">BbDialog</code> (or set the global default via
<code class="text-xs bg-muted px-1 py-0.5 rounded">AddBlazorBlueprintPrimitives(o =&gt; o.DefaultStrategy = OverlayRenderingStrategy.Native)</code>).
The browser shows the dialog in the top layer with native focus trapping, Escape handling and a
<code class="text-xs bg-muted px-1 py-0.5 rounded">::backdrop</code> &mdash; no portal host, no shared
scoped service, so it works across <code class="text-xs bg-muted px-1 py-0.5 rounded">InteractiveWebAssembly</code>
islands and static layouts. It falls back to the JS path automatically if the browser lacks
<code class="text-xs bg-muted px-1 py-0.5 rounded">showModal()</code>. Other overlay components (Popover,
Tooltip, Select, Sheet, AlertDialog) still use the portal path for now.
</p>
</section>

<!-- Troubleshooting -->
<section class="space-y-4">
<h2 class="text-2xl font-semibold">Troubleshooting checklist</h2>
Expand Down
15 changes: 14 additions & 1 deletion src/BlazorBlueprint.Components/Components/Dialog/BbDialog.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
@namespace BlazorBlueprint.Components
@using BlazorBlueprint.Primitives.Services

@*
Styled Dialog component wrapper.
Expand All @@ -9,7 +10,8 @@
OpenChanged="@OpenChanged"
DefaultOpen="@DefaultOpen"
OnOpenChange="@OnOpenChange"
Modal="@Modal">
Modal="@Modal"
RenderingStrategy="@RenderingStrategy">
@ChildContent
</BlazorBlueprint.Primitives.Dialog.BbDialog>

Expand Down Expand Up @@ -52,4 +54,15 @@
/// </summary>
[Parameter]
public bool Modal { get; set; } = true;

/// <summary>
/// Overrides how this dialog renders. When null, the global default configured via
/// <c>AddBlazorBlueprintPrimitives(configureOverlays)</c> applies.
/// <see cref="OverlayRenderingStrategy.Native"/> renders through the browser's built-in
/// <c>&lt;dialog&gt;</c> element, which works across Blazor render-mode boundaries
/// (e.g. InteractiveWebAssembly) without a portal host.
/// Defaults to null (use global default, which is <see cref="OverlayRenderingStrategy.JavaScript"/>).
/// </summary>
[Parameter]
public OverlayRenderingStrategy? RenderingStrategy { get; set; }
}
Loading