feat(centralui): DialogHost ShowAsync<T> custom-content + focus trap/restore + backdrop hook (T33a)

This commit is contained in:
Joseph Doherty
2026-06-18 19:24:15 -04:00
parent c0aaba17ea
commit 4755ceee81
12 changed files with 438 additions and 23 deletions
@@ -0,0 +1,63 @@
// Focus management helpers for the centralised DialogHost (T33a). Small and
// dependency-free: capture/restore the trigger element's focus across a modal's
// lifetime, and trap Tab focus inside an open modal. Blazor calls these via
// JS interop (window.sbDialog.*); they are no-ops when there is nothing to act
// on, so prerender / loose-mode test calls do no harm.
(function () {
'use strict';
// CSS selector for elements that can receive keyboard focus within the modal.
const FOCUSABLE =
"a[href],button:not([disabled]),input:not([disabled])," +
"select:not([disabled]),textarea:not([disabled])," +
"[tabindex]:not([tabindex='-1'])";
// The element that had focus when the most recent dialog opened. Module-level
// so it survives between the capture (on open) and restore (on close) calls.
let savedActiveElement = null;
window.sbDialog = {
// Stash the currently focused element so it can be restored on close.
// Called BEFORE the modal steals focus.
captureActiveElement: function () {
savedActiveElement = document.activeElement;
},
// Return focus to the stashed element if it is still in the document,
// then clear the stash so a later dialog starts fresh.
restoreActiveElement: function () {
const el = savedActiveElement;
savedActiveElement = null;
if (el && typeof el.focus === 'function' && el.isConnected) {
el.focus();
}
},
// Cycle focus within the modal: at the first focusable element a
// Shift+Tab wraps to the last; at the last a Tab wraps to the first. In
// both cases the default browser move (which would leave the modal) is
// prevented. Anywhere else, the native Tab order applies untouched.
focusTrap: function (modalEl, shiftKey) {
if (!modalEl) return;
const focusable = modalEl.querySelectorAll(FOCUSABLE);
if (focusable.length === 0) return;
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
// Blazor interop does not hand us the DOM event, but window.event is
// populated during the synchronous keydown dispatch on the engines we
// target — guard the preventDefault so the wrap still works if it is
// ever absent (the explicit .focus() override is the real trap).
const evt = window.event;
if (shiftKey && active === first) {
last.focus();
if (evt) evt.preventDefault();
} else if (!shiftKey && active === last) {
first.focus();
if (evt) evt.preventDefault();
}
}
};
})();