Skip to content

feat: add native dialog - #482

Merged
mathewtaylor merged 2 commits into
blazorblueprintui:developfrom
manuel-rw:feat/native-dialog
Sep 13, 2026
Merged

mathewtaylor merged 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.

@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.

Both must-fix items are done, and I checked them in a browser rather than reading the diff.

1. Clicking inside the dialog no longer closes it. The getBoundingClientRect comparison is the right call, and the comment explaining why e.target === dialog is ambiguous for a top-layer element is the part a future reader will need. Verified on the demo: a click 8px inside the top-left corner still resolves to DIALOG#dialog-54-content — the ambiguity is real — and the dialog stays open. A genuine backdrop click 40px outside the box still closes it, so the fix did not cost dismissal.

2. Focus is restored to the trigger. After Escape, document.activeElement is the Open Native Dialog button. It was <body> before. Letting close() run before unmount and taking the browser's own restoration is the cleaner of the two routes I suggested.

On the decisions:

  • LockScroll — documenting the difference is a fine answer to the question I asked, and the XML doc says plainly that it is not applied under Native and why. That is what I wanted: not a parameter that quietly does nothing.
  • IsDialogSupportedAsync now caches only true, with a comment naming the prerender/disconnect case. That was the whole concern.

Also checked, because this touches shared files: the default JavaScript path is untouched in behaviour — the standard dialog still opens with role="dialog", still locks body to overflow: hidden, still moves focus inside, and does not render a <dialog>. That is the regression risk for everyone who never opts in, and it is clean.

Merged develop in locally to check: builds clean, 178/178 tests pass, no conflicts.

Thanks for seeing this through — the two bugs were subtle ones, and the fixes address the cause rather than the symptom.

@mathewtaylor
mathewtaylor merged commit a30265d into blazorblueprintui:develop Sep 13, 2026
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