Skip to content
Merged
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
53 changes: 53 additions & 0 deletions ShellUI.Tests/PackageBehaviorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,59 @@ public void Dropdown_ClosesOnEscapeButNotOnOtherKeys()
Assert.Equal(new[] { false }, changes);
}

[Fact]
public void DialogContent_TrapsFocusWhileOpenAndReleasesItOnClose()
{
using var ctx = new BunitContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;

var dialog = ctx.Render<Dialog>(p => p
.Add(c => c.Open, true)
.AddChildContent<DialogContent>(c => c.AddChildContent("Body")));

var trap = ctx.JSInterop.VerifyInvoke("ShellUI.trapFocus");
ctx.JSInterop.VerifyNotInvoke("ShellUI.releaseFocus");

dialog.Render(p => p.Add(c => c.Open, false));

var release = ctx.JSInterop.VerifyInvoke("ShellUI.releaseFocus");
Assert.Equal(trap.Arguments[0], release.Arguments[0]);
}

[Fact]
public void SheetContent_TrapsFocusWhileOpen()
{
using var ctx = new BunitContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;

ctx.Render<Sheet>(p => p
.Add(c => c.Open, true)
.AddChildContent<SheetContent>(c => c.AddChildContent("Body")));

ctx.JSInterop.VerifyInvoke("ShellUI.trapFocus");
}

[Fact]
public void LegacySheet_TrapsFocusAndReleasesItWhenEscapeCloses()
{
using var ctx = new BunitContext();
ctx.JSInterop.Mode = JSRuntimeMode.Loose;
bool? open = null;

var sheet = ctx.Render<Sheet>(p => p
.Add(c => c.Open, true)
.Add(c => c.Title, "Settings")
.Add(c => c.OpenChanged, EventCallback.Factory.Create<bool>(this, v => open = v))
.AddChildContent("Body"));

ctx.JSInterop.VerifyInvoke("ShellUI.trapFocus");

sheet.Find("[role=dialog]").KeyDown(new KeyboardEventArgs { Key = "Escape" });

Assert.False(open);
ctx.JSInterop.VerifyInvoke("ShellUI.releaseFocus");
}

[Fact]
public void TimePicker_ShowsASetValue()
{
Expand Down
17 changes: 16 additions & 1 deletion src/ShellUI.Components/Components/AlertDialogContent.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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 { }
Expand Down
17 changes: 16 additions & 1 deletion src/ShellUI.Components/Components/DialogContent.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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)
Expand All @@ -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 { }
Expand Down
40 changes: 39 additions & 1 deletion src/ShellUI.Components/Components/Drawer.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
@namespace ShellUI.Components
@using Microsoft.JSInterop
@implements IAsyncDisposable
@inject IJSRuntime JS

@if (UseCompositional)
{
Expand All @@ -9,7 +12,7 @@
else if (Open)
{
<div class="fixed inset-0 z-50 bg-black/80 animate-in fade-in-0" @onclick="Close"></div>
<div class="@DrawerVariants.Get(Side, Class)" @onclick:stopPropagation="true" @attributes="AdditionalAttributes">
<div @ref="_panel" tabindex="-1" role="dialog" aria-modal="true" @onkeydown="OnPanelKeyDownAsync" class="outline-none @DrawerVariants.Get(Side, Class)" @onclick:stopPropagation="true" @attributes="AdditionalAttributes">
<div class="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted"></div>
<div class="grid gap-1.5 p-4 text-center sm:text-left">
@if (!string.IsNullOrEmpty(Title))
Expand Down Expand Up @@ -55,4 +58,39 @@ else if (Open)
{
if (Open != value) { Open = value; await OpenChanged.InvokeAsync(Open); StateHasChanged(); }
}

private ElementReference _panel;
private readonly string _trapId = Guid.NewGuid().ToString("N");
private bool _focused;
private bool _trapped;

private async Task OnPanelKeyDownAsync(KeyboardEventArgs e)
{
if (e.Key == "Escape") await Close();
}

protected override async Task OnAfterRenderAsync(bool firstRender)
{
var panelOpen = Open && !UseCompositional;
if (panelOpen && !_focused)
{
_focused = true;
try { await JS.InvokeVoidAsync("ShellUI.trapFocus", _trapId, _panel); _trapped = true; } catch { }
try { await _panel.FocusAsync(); } catch (InvalidOperationException) { }
}
else if (!panelOpen)
{
_focused = false;
await ReleaseFocusAsync();
}
}

private async Task ReleaseFocusAsync()
{
if (!_trapped) return;
_trapped = false;
try { await JS.InvokeVoidAsync("ShellUI.releaseFocus", _trapId); } catch { }
}

public async ValueTask DisposeAsync() => await ReleaseFocusAsync();
}
17 changes: 16 additions & 1 deletion src/ShellUI.Components/Components/DrawerContent.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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)
Expand All @@ -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 { }
Expand Down
40 changes: 39 additions & 1 deletion src/ShellUI.Components/Components/Sheet.razor
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
@namespace ShellUI.Components
@using Microsoft.JSInterop
@implements IAsyncDisposable
@inject IJSRuntime JS

@if (UseCompositional)
{
Expand All @@ -9,7 +12,7 @@
else if (Open)
{
<div class="fixed inset-0 z-50 bg-background/80 backdrop-blur-sm animate-in fade-in-0" @onclick="Close"></div>
<div class="@SheetVariants.Get(Side, Class)" @onclick:stopPropagation="true" @attributes="AdditionalAttributes">
<div @ref="_panel" tabindex="-1" role="dialog" aria-modal="true" @onkeydown="OnPanelKeyDownAsync" class="outline-none @SheetVariants.Get(Side, Class)" @onclick:stopPropagation="true" @attributes="AdditionalAttributes">
<div class="flex flex-col space-y-2">
<div class="flex items-center justify-between">
@if (!string.IsNullOrEmpty(Title))
Expand Down Expand Up @@ -63,4 +66,39 @@ else if (Open)
{
if (Open != value) { Open = value; await OpenChanged.InvokeAsync(Open); StateHasChanged(); }
}

private ElementReference _panel;
private readonly string _trapId = Guid.NewGuid().ToString("N");
private bool _focused;
private bool _trapped;

private async Task OnPanelKeyDownAsync(KeyboardEventArgs e)
{
if (e.Key == "Escape") await Close();
}

protected override async Task OnAfterRenderAsync(bool firstRender)
{
var panelOpen = Open && !UseCompositional;
if (panelOpen && !_focused)
{
_focused = true;
try { await JS.InvokeVoidAsync("ShellUI.trapFocus", _trapId, _panel); _trapped = true; } catch { }
try { await _panel.FocusAsync(); } catch (InvalidOperationException) { }
}
else if (!panelOpen)
{
_focused = false;
await ReleaseFocusAsync();
}
}

private async Task ReleaseFocusAsync()
{
if (!_trapped) return;
_trapped = false;
try { await JS.InvokeVoidAsync("ShellUI.releaseFocus", _trapId); } catch { }
}

public async ValueTask DisposeAsync() => await ReleaseFocusAsync();
}
17 changes: 16 additions & 1 deletion src/ShellUI.Components/Components/SheetContent.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand All @@ -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)
Expand All @@ -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 { }
Expand Down
Loading
Loading