Skip to content

feat: add native dialog - #482

Open
manuel-rw wants to merge 2 commits into
blazorblueprintui:developfrom
manuel-rw:feat/native-dialog
Open

feat: add native dialog#482
manuel-rw wants to merge 2 commits into
blazorblueprintui:developfrom
manuel-rw:feat/native-dialog

Conversation

@manuel-rw

Copy link
Copy Markdown

As outlined in #376 , there are some design limitations of the current dialog component. It requires to have a BbPortalHost within the same circuit. This makes it not possible to render the portal host in SSR but show a dialog within a WebAssembly component. Additionally, due to the network latency and the roundtrip required by a InteractiveServer call for a dialog, they can feel sluggish.

This PR implements the support for the native dialog with a fallback to the interactive dialog if the browser does not support it. The native dialog is nowadays well implemented across many browsers. This change also adds an example for opening the dialog using OpenAsync and points out the limitation of the DialogRef, since I ran into this limitation in my own app.

The change should be backward compatible but I only verified using the Sample project and my own project.

Note

For the sake of transparency, this PR was partially generated by AI. I used it to refactor, organize and write tests. Smoke tests were conducted manually.

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)

Testing Checklist

  • Blazor Server
  • Blazor WebAssembly
  • Blazor Hybrid (MAUI)
  • Keyboard navigation / accessibility
  • Dark mode

Related Issues

#479
#376

@manuel-rw

Copy link
Copy Markdown
Author

I don't use MAUI or native desktop apps. So we may need to organize with #478

@manuel-rw

Copy link
Copy Markdown
Author

@mathewtaylor could you take a look?

@mathewtaylor mathewtaylor left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — it's a well-made PR, and it solves a real problem. I checked it out, built it, and drove it in a browser rather than only reading the diff. Two bugs to fix, one behaviour to decide on, and some smaller things.

A note on process first: thank you for flagging the AI assistance up front. That's the right call and it doesn't count against the PR — the design decisions here are clearly considered.


Must fix

1. Clicking inside the dialog closes it

setupDialog treats any click whose target is the dialog element as a backdrop click:

const onClick = (e) => {
    if (e.target === dialog) {
        dotNetRef.invokeMethodAsync('JsOnNativeBackdropClick');
    }
};

That's the usual idiom, and it's only safe when the dialog has no padding of its own. Here it has 24px — GetClassNames() keeps p-6 for both strategies, and the reset in blazorblueprint-input.css sets padding: 0 specifically so the Tailwind class wins.

So the entire 24px band inside the dialog's border counts as backdrop. Reproduced on the demo page:

const d = document.querySelector('dialog[data-state]');
const r = d.getBoundingClientRect();
document.elementFromPoint(r.x + 8, r.y + 8);   // → DIALOG#dialog-18-content

Clicking that point — visually well inside the box, above the title — closes the dialog. The JS path doesn't have this: it renders the content <div> and the overlay as separate elements, so the same click lands on the content and is correctly ignored. I verified both.

Comparing the click coordinates against getBoundingClientRect() is the usual fix, though it has its own edge case with a scrolled dialog. Wrapping @ChildContent in an inner element and testing !inner.contains(e.target) may be tidier given you already control the markup.

2. Focus isn't restored to the trigger on close

After closing with Escape, document.activeElement is <body>. Keyboard users lose their place, and it's a regression against the JS path.

INativeOverlayService.FocusTriggerAsync is declared and implemented, but nothing calls it — CleanupNativeAsync disposes the listeners and closes the element without touching focus.

Worth knowing why the browser doesn't cover this for you: a native <dialog> does restore focus on close(), but the element is unmounted by @if (Context.IsOpen) rather than closed first, so that never runs. Either call close() and let the browser handle it before unmounting, or call the FocusTriggerAsync you've already written.


Worth deciding before merge

LockScroll is silently ignored in native mode

The parameter is still accepted and still documented, but HandleNativeLifecycleAsync returns before the scroll-lock block. showModal() makes the background inert to interaction, but it doesn't stop it scrolling — with the native dialog open, body computes to overflow: visible, where the JS path sets hidden.

A parameter that quietly does nothing is worse than one that isn't offered, so I'd rather it either applied the same lock or documented the difference explicitly. Your call which.

Same question applies to TrapFocus, though in the other direction — native always traps, so TrapFocus="false" can't be honoured. Probably just needs saying in the XML doc.


Smaller

  • IsDialogSupportedAsync caches a failure permanently. If the first call lands while interop is unavailable, dialogSupported = false sticks for the lifetime of the scope, and the "browser does not support <dialog>" warning then fires on every open in a browser that supports it perfectly well. Caching only the successful result would avoid that.
  • _useNative is computed once in OnInitialized, in both the primitive and the styled component, so changing RenderingStrategy after first render has no effect. Fine if intended — worth a line in the XML doc if so.
  • FocusDialogAsync runs immediately after showModal(), which has already moved focus per spec. That can override a consumer's autofocus. It may be redundant.

What's good

Genuinely — several things here are done better than I'd expect:

  • Dropping role="dialog", aria-modal="true" and tabindex="-1" on the native element is correct, and most implementations get it wrong by keeping them. A modal <dialog> has those semantics implicitly and aria-modal is actively discouraged on it.
  • Backward compatible by default. JavaScript stays the default at both the global and per-component level, so nothing changes for anyone who doesn't opt in.
  • The comment in native-dialog.js about avoiding top-level lexical bindings because WASM's dynamic import() can re-evaluate a module — that's a real failure mode and I'm glad it's written down rather than just worked around.
  • Tests, demo page, code examples, API reference entries, changelog and API surface snapshots are all updated. That's the full checklist and it's rare to get it in one pass.

Merge state

Only CHANGELOG.md conflicts, from entries that landed on develop today — trivial to resolve. With that fixed it builds clean and all 83 tests pass, including the overlay convention guards added since you opened this.

One thing to know: #479, which you linked, is now closed. The cause turned out to be a static layout in a per-page-interactive app rather than the portal handshake itself, and the fix there was diagnostics. That doesn't reduce the value of this PR — the sluggishness argument from discussion #376 stands on its own, and native <dialog> is the right direction.

Happy to take another look once the two above are sorted.

@manuel-rw
manuel-rw requested a review from mathewtaylor August 27, 2026 09:15
@manuel-rw
manuel-rw force-pushed the feat/native-dialog branch 3 times, most recently from 55b35ae to d7e8992 Compare August 27, 2026 09:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants