From cbee7dba33e670a281d2d5573e4c0f6b259193c1 Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 17 Oct 2026 22:23:49 +0200 Subject: [PATCH 1/3] fix: trap focus inside Dialog, AlertDialog, Sheet and Drawer --- .../Components/AlertDialogContent.razor | 17 ++++++- .../Components/DialogContent.razor | 17 ++++++- .../Components/DrawerContent.razor | 17 ++++++- .../Components/SheetContent.razor | 17 ++++++- src/ShellUI.Components/wwwroot/shellui.js | 46 +++++++++++++++++++ .../Templates/AlertDialogContentTemplate.cs | 17 ++++++- .../Templates/DialogContentTemplate.cs | 17 ++++++- .../Templates/DrawerContentTemplate.cs | 17 ++++++- .../Templates/SheetContentTemplate.cs | 17 ++++++- .../Templates/ShellUIJsTemplate.cs | 44 ++++++++++++++++++ 10 files changed, 218 insertions(+), 8 deletions(-) diff --git a/src/ShellUI.Components/Components/AlertDialogContent.razor b/src/ShellUI.Components/Components/AlertDialogContent.razor index e353ef8..54b12e4 100644 --- a/src/ShellUI.Components/Components/AlertDialogContent.razor +++ b/src/ShellUI.Components/Components/AlertDialogContent.razor @@ -21,6 +21,8 @@ private bool _isLocked; private bool _focused; private ElementReference _content; + private readonly string _trapId = Guid.NewGuid().ToString("N"); + private bool _trapped; // Escape dismisses an alert dialog as Cancel; the backdrop does not. private async Task OnKeyDownAsync(KeyboardEventArgs e) @@ -34,9 +36,14 @@ if (isOpen && !_focused) { _focused = true; + try { await JS.InvokeVoidAsync("ShellUI.trapFocus", _trapId, _content); _trapped = true; } catch { } try { await _content.FocusAsync(); } catch (InvalidOperationException) { } } - else if (!isOpen) _focused = false; + else if (!isOpen) + { + _focused = false; + await ReleaseFocusAsync(); + } var shouldBeLocked = Parent?.IsOpen == true; if (shouldBeLocked && !_isLocked) @@ -49,8 +56,16 @@ } } + private async Task ReleaseFocusAsync() + { + if (!_trapped) return; + _trapped = false; + try { await JS.InvokeVoidAsync("ShellUI.releaseFocus", _trapId); } catch { } + } + public async ValueTask DisposeAsync() { + await ReleaseFocusAsync(); if (_isLocked) { try { await JS.InvokeVoidAsync("ShellUI.unlockBodyScroll"); } catch { } diff --git a/src/ShellUI.Components/Components/DialogContent.razor b/src/ShellUI.Components/Components/DialogContent.razor index c2fa0e9..d17f03e 100644 --- a/src/ShellUI.Components/Components/DialogContent.razor +++ b/src/ShellUI.Components/Components/DialogContent.razor @@ -25,6 +25,8 @@ private bool _isLocked; private bool _focused; private ElementReference _content; + private readonly string _trapId = Guid.NewGuid().ToString("N"); + private bool _trapped; private async Task Close() { @@ -45,9 +47,14 @@ if (isOpen && !_focused) { _focused = true; + try { await JS.InvokeVoidAsync("ShellUI.trapFocus", _trapId, _content); _trapped = true; } catch { } try { await _content.FocusAsync(); } catch (InvalidOperationException) { } } - else if (!isOpen) _focused = false; + else if (!isOpen) + { + _focused = false; + await ReleaseFocusAsync(); + } var shouldBeLocked = Dialog?.Open == true; if (shouldBeLocked && !_isLocked) @@ -60,8 +67,16 @@ } } + private async Task ReleaseFocusAsync() + { + if (!_trapped) return; + _trapped = false; + try { await JS.InvokeVoidAsync("ShellUI.releaseFocus", _trapId); } catch { } + } + public async ValueTask DisposeAsync() { + await ReleaseFocusAsync(); if (_isLocked) { try { await JS.InvokeVoidAsync("ShellUI.unlockBodyScroll"); } catch { } diff --git a/src/ShellUI.Components/Components/DrawerContent.razor b/src/ShellUI.Components/Components/DrawerContent.razor index d3ea80a..be25d22 100644 --- a/src/ShellUI.Components/Components/DrawerContent.razor +++ b/src/ShellUI.Components/Components/DrawerContent.razor @@ -24,6 +24,8 @@ private bool _isLocked; private bool _focused; private ElementReference _content; + private readonly string _trapId = Guid.NewGuid().ToString("N"); + private bool _trapped; private async Task Close() { @@ -41,9 +43,14 @@ if (isOpen && !_focused) { _focused = true; + try { await JS.InvokeVoidAsync("ShellUI.trapFocus", _trapId, _content); _trapped = true; } catch { } try { await _content.FocusAsync(); } catch (InvalidOperationException) { } } - else if (!isOpen) _focused = false; + else if (!isOpen) + { + _focused = false; + await ReleaseFocusAsync(); + } var shouldBeLocked = Parent?.Open == true; if (shouldBeLocked && !_isLocked) @@ -56,8 +63,16 @@ } } + private async Task ReleaseFocusAsync() + { + if (!_trapped) return; + _trapped = false; + try { await JS.InvokeVoidAsync("ShellUI.releaseFocus", _trapId); } catch { } + } + public async ValueTask DisposeAsync() { + await ReleaseFocusAsync(); if (_isLocked) { try { await JS.InvokeVoidAsync("ShellUI.unlockBodyScroll"); } catch { } diff --git a/src/ShellUI.Components/Components/SheetContent.razor b/src/ShellUI.Components/Components/SheetContent.razor index 4c3891b..78902cf 100644 --- a/src/ShellUI.Components/Components/SheetContent.razor +++ b/src/ShellUI.Components/Components/SheetContent.razor @@ -23,6 +23,8 @@ private bool _isLocked; private bool _focused; private ElementReference _content; + private readonly string _trapId = Guid.NewGuid().ToString("N"); + private bool _trapped; private async Task Close() { @@ -40,9 +42,14 @@ if (isOpen && !_focused) { _focused = true; + try { await JS.InvokeVoidAsync("ShellUI.trapFocus", _trapId, _content); _trapped = true; } catch { } try { await _content.FocusAsync(); } catch (InvalidOperationException) { } } - else if (!isOpen) _focused = false; + else if (!isOpen) + { + _focused = false; + await ReleaseFocusAsync(); + } var shouldBeLocked = Parent?.Open == true; if (shouldBeLocked && !_isLocked) @@ -55,8 +62,16 @@ } } + private async Task ReleaseFocusAsync() + { + if (!_trapped) return; + _trapped = false; + try { await JS.InvokeVoidAsync("ShellUI.releaseFocus", _trapId); } catch { } + } + public async ValueTask DisposeAsync() { + await ReleaseFocusAsync(); if (_isLocked) { try { await JS.InvokeVoidAsync("ShellUI.unlockBodyScroll"); } catch { } diff --git a/src/ShellUI.Components/wwwroot/shellui.js b/src/ShellUI.Components/wwwroot/shellui.js index 53fcaca..3c67ea9 100644 --- a/src/ShellUI.Components/wwwroot/shellui.js +++ b/src/ShellUI.Components/wwwroot/shellui.js @@ -98,6 +98,50 @@ Object.assign(window.ShellUI, { } }, + // A stack, so a dialog opened from a dialog traps Tab inside the top one. + _focusTraps: [], + _focusableSelector: 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]', + trapFocus: function (handle, element) { + const shell = window.ShellUI; + if (!element || shell._focusTraps.some(t => t.handle === handle)) return; + if (shell._focusTraps.length === 0) document.addEventListener("keydown", shell._onFocusTrapKeyDown, true); + shell._focusTraps.push({ handle: handle, element: element, previous: document.activeElement }); + }, + releaseFocus: function (handle) { + const shell = window.ShellUI; + const index = shell._focusTraps.findIndex(t => t.handle === handle); + if (index < 0) return; + const trap = shell._focusTraps.splice(index, 1)[0]; + if (shell._focusTraps.length === 0) document.removeEventListener("keydown", shell._onFocusTrapKeyDown, true); + if (trap.previous && trap.previous.isConnected && typeof trap.previous.focus === "function") trap.previous.focus(); + }, + _onFocusTrapKeyDown: function (e) { + if (e.key !== "Tab") return; + const shell = window.ShellUI; + const trap = shell._focusTraps[shell._focusTraps.length - 1]; + if (!trap || !trap.element.isConnected) return; + const root = trap.element; + const items = Array.from(root.querySelectorAll(shell._focusableSelector)).filter(el => el.getClientRects().length > 0); + const active = document.activeElement; + if (items.length === 0) { + e.preventDefault(); + root.focus(); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + if (!root.contains(active)) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } else if (e.shiftKey && (active === first || active === root)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + }, + // Bubble phase: scrolling inside the dropdown doesn't fire, only page scroll does. _dismissHandlers: new Map(), onDismissEvents: function (handle, dotNetRef) { @@ -130,5 +174,7 @@ export function registerShortcut(handle, key, ctrl, meta, shift, alt, dotNetRef) export function unregisterShortcut(handle) { return window.ShellUI.unregisterShortcut(handle); } export function lockBodyScroll() { return window.ShellUI.lockBodyScroll(); } export function unlockBodyScroll() { return window.ShellUI.unlockBodyScroll(); } +export function trapFocus(handle, element) { return window.ShellUI.trapFocus(handle, element); } +export function releaseFocus(handle) { return window.ShellUI.releaseFocus(handle); } export function onDismissEvents(handle, dotNetRef) { return window.ShellUI.onDismissEvents(handle, dotNetRef); } export function offDismissEvents(handle) { return window.ShellUI.offDismissEvents(handle); } diff --git a/src/ShellUI.Templates/Templates/AlertDialogContentTemplate.cs b/src/ShellUI.Templates/Templates/AlertDialogContentTemplate.cs index 1afa794..c13f63b 100644 --- a/src/ShellUI.Templates/Templates/AlertDialogContentTemplate.cs +++ b/src/ShellUI.Templates/Templates/AlertDialogContentTemplate.cs @@ -38,6 +38,8 @@ @inject IJSRuntime JS private bool _isLocked; private bool _focused; private ElementReference _content; + private readonly string _trapId = Guid.NewGuid().ToString(""N""); + private bool _trapped; // Escape dismisses an alert dialog as Cancel; the backdrop does not. private async Task OnKeyDownAsync(KeyboardEventArgs e) @@ -51,9 +53,14 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (isOpen && !_focused) { _focused = true; + try { await JS.InvokeVoidAsync(""ShellUI.trapFocus"", _trapId, _content); _trapped = true; } catch { } try { await _content.FocusAsync(); } catch (InvalidOperationException) { } } - else if (!isOpen) _focused = false; + else if (!isOpen) + { + _focused = false; + await ReleaseFocusAsync(); + } var shouldBeLocked = Parent?.IsOpen == true; if (shouldBeLocked && !_isLocked) @@ -66,8 +73,16 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } + private async Task ReleaseFocusAsync() + { + if (!_trapped) return; + _trapped = false; + try { await JS.InvokeVoidAsync(""ShellUI.releaseFocus"", _trapId); } catch { } + } + public async ValueTask DisposeAsync() { + await ReleaseFocusAsync(); if (_isLocked) { try { await JS.InvokeVoidAsync(""ShellUI.unlockBodyScroll""); } catch { } diff --git a/src/ShellUI.Templates/Templates/DialogContentTemplate.cs b/src/ShellUI.Templates/Templates/DialogContentTemplate.cs index 73522dc..e85e577 100644 --- a/src/ShellUI.Templates/Templates/DialogContentTemplate.cs +++ b/src/ShellUI.Templates/Templates/DialogContentTemplate.cs @@ -42,6 +42,8 @@ @inject IJSRuntime JS private bool _isLocked; private bool _focused; private ElementReference _content; + private readonly string _trapId = Guid.NewGuid().ToString(""N""); + private bool _trapped; private async Task Close() { @@ -62,9 +64,14 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (isOpen && !_focused) { _focused = true; + try { await JS.InvokeVoidAsync(""ShellUI.trapFocus"", _trapId, _content); _trapped = true; } catch { } try { await _content.FocusAsync(); } catch (InvalidOperationException) { } } - else if (!isOpen) _focused = false; + else if (!isOpen) + { + _focused = false; + await ReleaseFocusAsync(); + } var shouldBeLocked = Dialog?.Open == true; if (shouldBeLocked && !_isLocked) @@ -77,8 +84,16 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } + private async Task ReleaseFocusAsync() + { + if (!_trapped) return; + _trapped = false; + try { await JS.InvokeVoidAsync(""ShellUI.releaseFocus"", _trapId); } catch { } + } + public async ValueTask DisposeAsync() { + await ReleaseFocusAsync(); if (_isLocked) { try { await JS.InvokeVoidAsync(""ShellUI.unlockBodyScroll""); } catch { } diff --git a/src/ShellUI.Templates/Templates/DrawerContentTemplate.cs b/src/ShellUI.Templates/Templates/DrawerContentTemplate.cs index 3df3821..b8d0cac 100644 --- a/src/ShellUI.Templates/Templates/DrawerContentTemplate.cs +++ b/src/ShellUI.Templates/Templates/DrawerContentTemplate.cs @@ -43,6 +43,8 @@ @inject IJSRuntime JS private bool _isLocked; private bool _focused; private ElementReference _content; + private readonly string _trapId = Guid.NewGuid().ToString(""N""); + private bool _trapped; private async Task Close() { @@ -60,9 +62,14 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (isOpen && !_focused) { _focused = true; + try { await JS.InvokeVoidAsync(""ShellUI.trapFocus"", _trapId, _content); _trapped = true; } catch { } try { await _content.FocusAsync(); } catch (InvalidOperationException) { } } - else if (!isOpen) _focused = false; + else if (!isOpen) + { + _focused = false; + await ReleaseFocusAsync(); + } var shouldBeLocked = Parent?.Open == true; if (shouldBeLocked && !_isLocked) @@ -75,8 +82,16 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } + private async Task ReleaseFocusAsync() + { + if (!_trapped) return; + _trapped = false; + try { await JS.InvokeVoidAsync(""ShellUI.releaseFocus"", _trapId); } catch { } + } + public async ValueTask DisposeAsync() { + await ReleaseFocusAsync(); if (_isLocked) { try { await JS.InvokeVoidAsync(""ShellUI.unlockBodyScroll""); } catch { } diff --git a/src/ShellUI.Templates/Templates/SheetContentTemplate.cs b/src/ShellUI.Templates/Templates/SheetContentTemplate.cs index 7821cf7..ff778c0 100644 --- a/src/ShellUI.Templates/Templates/SheetContentTemplate.cs +++ b/src/ShellUI.Templates/Templates/SheetContentTemplate.cs @@ -42,6 +42,8 @@ @inject IJSRuntime JS private bool _isLocked; private bool _focused; private ElementReference _content; + private readonly string _trapId = Guid.NewGuid().ToString(""N""); + private bool _trapped; private async Task Close() { @@ -59,9 +61,14 @@ protected override async Task OnAfterRenderAsync(bool firstRender) if (isOpen && !_focused) { _focused = true; + try { await JS.InvokeVoidAsync(""ShellUI.trapFocus"", _trapId, _content); _trapped = true; } catch { } try { await _content.FocusAsync(); } catch (InvalidOperationException) { } } - else if (!isOpen) _focused = false; + else if (!isOpen) + { + _focused = false; + await ReleaseFocusAsync(); + } var shouldBeLocked = Parent?.Open == true; if (shouldBeLocked && !_isLocked) @@ -74,8 +81,16 @@ protected override async Task OnAfterRenderAsync(bool firstRender) } } + private async Task ReleaseFocusAsync() + { + if (!_trapped) return; + _trapped = false; + try { await JS.InvokeVoidAsync(""ShellUI.releaseFocus"", _trapId); } catch { } + } + public async ValueTask DisposeAsync() { + await ReleaseFocusAsync(); if (_isLocked) { try { await JS.InvokeVoidAsync(""ShellUI.unlockBodyScroll""); } catch { } diff --git a/src/ShellUI.Templates/Templates/ShellUIJsTemplate.cs b/src/ShellUI.Templates/Templates/ShellUIJsTemplate.cs index bc2bce7..9c504fb 100644 --- a/src/ShellUI.Templates/Templates/ShellUIJsTemplate.cs +++ b/src/ShellUI.Templates/Templates/ShellUIJsTemplate.cs @@ -159,6 +159,50 @@ function handleDrop(e) { } }, + // A stack, so a dialog opened from a dialog traps Tab inside the top one. + _focusTraps: [], + _focusableSelector: 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable="true"]', + trapFocus: function (handle, element) { + const shell = window.ShellUI; + if (!element || shell._focusTraps.some(t => t.handle === handle)) return; + if (shell._focusTraps.length === 0) document.addEventListener("keydown", shell._onFocusTrapKeyDown, true); + shell._focusTraps.push({ handle: handle, element: element, previous: document.activeElement }); + }, + releaseFocus: function (handle) { + const shell = window.ShellUI; + const index = shell._focusTraps.findIndex(t => t.handle === handle); + if (index < 0) return; + const trap = shell._focusTraps.splice(index, 1)[0]; + if (shell._focusTraps.length === 0) document.removeEventListener("keydown", shell._onFocusTrapKeyDown, true); + if (trap.previous && trap.previous.isConnected && typeof trap.previous.focus === "function") trap.previous.focus(); + }, + _onFocusTrapKeyDown: function (e) { + if (e.key !== "Tab") return; + const shell = window.ShellUI; + const trap = shell._focusTraps[shell._focusTraps.length - 1]; + if (!trap || !trap.element.isConnected) return; + const root = trap.element; + const items = Array.from(root.querySelectorAll(shell._focusableSelector)).filter(el => el.getClientRects().length > 0); + const active = document.activeElement; + if (items.length === 0) { + e.preventDefault(); + root.focus(); + return; + } + const first = items[0]; + const last = items[items.length - 1]; + if (!root.contains(active)) { + e.preventDefault(); + (e.shiftKey ? last : first).focus(); + } else if (e.shiftKey && (active === first || active === root)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && active === last) { + e.preventDefault(); + first.focus(); + } + }, + _dismissHandlers: new Map(), onDismissEvents: function (handle, dotNetRef) { const listener = () => dotNetRef.invokeMethodAsync("OnDismissEvent"); From 7aeba79b0d0a3c239001d7ac92ccc23c9911d0cf Mon Sep 17 00:00:00 2001 From: Shephard Tseisi Date: Sat, 17 Oct 2026 22:23:49 +0200 Subject: [PATCH 2/3] fix: focus the legacy Sheet and Drawer panels, trap focus and close on Escape --- .../Components/Drawer.razor | 40 ++++++++++++++++++- src/ShellUI.Components/Components/Sheet.razor | 40 ++++++++++++++++++- .../Templates/DrawerTemplate.cs | 40 ++++++++++++++++++- .../Templates/SheetTemplate.cs | 40 ++++++++++++++++++- 4 files changed, 156 insertions(+), 4 deletions(-) diff --git a/src/ShellUI.Components/Components/Drawer.razor b/src/ShellUI.Components/Components/Drawer.razor index 76bd72d..6c01d76 100644 --- a/src/ShellUI.Components/Components/Drawer.razor +++ b/src/ShellUI.Components/Components/Drawer.razor @@ -1,4 +1,7 @@ @namespace ShellUI.Components +@using Microsoft.JSInterop +@implements IAsyncDisposable +@inject IJSRuntime JS @if (UseCompositional) { @@ -9,7 +12,7 @@ else if (Open) {
-
+