Skip to content

Commit 9975d06

Browse files
adamintvnbaaij
andauthored
Return focus when dialog is closed to element focused before open (#4095)
* return dialog focus after close * block dialog open on getting active element * fix test, don't version file * remove extra newline * apply review suggestions * add interop mode of Loose to match other components * Update DialogInstance.cs Previously focused element is nullable --------- Co-authored-by: Vincent Baaij <[email protected]>
1 parent 7e6d5c7 commit 9975d06

File tree

5 files changed

+93
-9
lines changed

5 files changed

+93
-9
lines changed

src/Core/Components/Dialog/FluentDialog.razor.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,14 @@ public async Task CloseAsync(DialogResult dialogResult)
263263
{
264264
await Instance.Parameters.OnDialogResult.InvokeAsync(dialogResult);
265265
}
266+
267+
if (DialogContext is not null && Instance.PreviouslyFocusedElement is not null)
268+
{
269+
// Dialog does not close instantly, wait a little while to ensure that it has closed
270+
// before trying to set focus. If dialog is not closed, focus cannot be set.
271+
await Task.Delay(50);
272+
await DialogContext.DialogContainer.ReturnFocusAsync(Instance.PreviouslyFocusedElement);
273+
}
266274
}
267275
else
268276
{

src/Core/Components/Dialog/FluentDialogProvider.razor.cs

Lines changed: 67 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,31 @@
44

55
using Microsoft.AspNetCore.Components;
66
using Microsoft.AspNetCore.Components.Routing;
7+
using Microsoft.FluentUI.AspNetCore.Components.Extensions;
8+
using Microsoft.JSInterop;
79

810
namespace Microsoft.FluentUI.AspNetCore.Components;
911

10-
public partial class FluentDialogProvider : IDisposable
12+
public partial class FluentDialogProvider : IAsyncDisposable
1113
{
14+
private const string JAVASCRIPT_FILE = "./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Dialog/FluentDialogProvider.razor.js";
15+
1216
private readonly InternalDialogContext _internalDialogContext;
1317
private readonly RenderFragment _renderDialogs;
18+
private IJSObjectReference? _module;
1419

1520
[Inject]
1621
private IDialogService DialogService { get; set; } = default!;
1722

1823
[Inject]
1924
private NavigationManager NavigationManager { get; set; } = default!;
2025

26+
[Inject]
27+
private IJSRuntime JSRuntime { get; set; } = default!;
28+
29+
[Inject]
30+
private LibraryConfiguration LibraryConfiguration { get; set; } = default!;
31+
2132
/// <summary>
2233
/// Constructs an instance of <see cref="FluentToastProvider"/>.
2334
/// </summary>
@@ -40,20 +51,43 @@ protected override void OnInitialized()
4051
DialogService.OnDialogCloseRequested += DismissInstance;
4152
}
4253

54+
protected override async Task OnAfterRenderAsync(bool firstRender)
55+
{
56+
if (firstRender)
57+
{
58+
_module ??= await JSRuntime.InvokeAsync<IJSObjectReference>("import", JAVASCRIPT_FILE.FormatCollocatedUrl(LibraryConfiguration));
59+
}
60+
}
61+
4362
private void ShowDialog(IDialogReference dialogReference, Type? dialogComponent, DialogParameters parameters, object content)
4463
{
45-
DialogInstance dialog = new(dialogComponent, parameters, content);
46-
dialogReference.Instance = dialog;
64+
if (_module is null)
65+
{
66+
throw new InvalidOperationException("JS module is not loaded.");
67+
}
4768

48-
_internalDialogContext.References.Add(dialogReference);
49-
InvokeAsync(StateHasChanged);
69+
InvokeAsync(async () =>
70+
{
71+
var previouslyFocusedElement = await _module.InvokeAsync<IJSObjectReference>("getActiveElement");
72+
DialogInstance dialog = new(dialogComponent, parameters, content, previouslyFocusedElement);
73+
dialogReference.Instance = dialog;
74+
75+
_internalDialogContext.References.Add(dialogReference);
76+
});
5077
}
5178

5279
private async Task<IDialogReference> ShowDialogAsync(IDialogReference dialogReference, Type? dialogComponent, DialogParameters parameters, object content)
5380
{
54-
return await Task.Run(() =>
81+
if (_module is null)
5582
{
56-
DialogInstance dialog = new(dialogComponent, parameters, content);
83+
throw new InvalidOperationException("JS module is not loaded.");
84+
}
85+
86+
return await Task.Run(async () =>
87+
{
88+
var previouslyFocusedElement = await _module.InvokeAsync<IJSObjectReference>("getActiveElement");
89+
90+
DialogInstance dialog = new(dialogComponent, parameters, content, previouslyFocusedElement);
5791
dialogReference.Instance = dialog;
5892

5993
_internalDialogContext.References.Add(dialogReference);
@@ -130,6 +164,17 @@ internal void DismissInstance(string id, DialogResult result)
130164
}
131165
}
132166

167+
internal async Task ReturnFocusAsync(IJSObjectReference element)
168+
{
169+
if (_module is null)
170+
{
171+
throw new InvalidOperationException("JS module is not loaded.");
172+
}
173+
174+
await _module.InvokeVoidAsync("focusElement", element);
175+
await element.DisposeAsync();
176+
}
177+
133178
internal IDialogReference? GetDialogReference(string id)
134179
{
135180
return _internalDialogContext.References.SingleOrDefault(x => x.Id == id);
@@ -183,11 +228,25 @@ private async Task OnDismissAsync(DialogEventArgs args)
183228
}
184229
}
185230

186-
public void Dispose()
231+
public async ValueTask DisposeAsync()
187232
{
188233
if (NavigationManager != null)
189234
{
190235
NavigationManager.LocationChanged -= LocationChanged;
191236
}
237+
238+
try
239+
{
240+
if (_module is not null)
241+
{
242+
await _module.DisposeAsync();
243+
}
244+
}
245+
catch (Exception ex) when (ex is JSDisconnectedException ||
246+
ex is OperationCanceledException)
247+
{
248+
// The JSRuntime side may routinely be gone already if the reason we're disposing is that
249+
// the client disconnected. This is not an error.
250+
}
192251
}
193252
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export function getActiveElement() {
2+
return document.activeElement
3+
}
4+
5+
export function focusElement(element) {
6+
if (!!element) {
7+
element.focus();
8+
}
9+
}

src/Core/Components/Dialog/Services/DialogInstance.cs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,19 @@
22
// This file is licensed to you under the MIT License.
33
// ------------------------------------------------------------------------
44

5+
using Microsoft.JSInterop;
6+
57
namespace Microsoft.FluentUI.AspNetCore.Components;
68

79
public sealed class DialogInstance
810
{
9-
public DialogInstance(Type? type, DialogParameters parameters, object content)
11+
public DialogInstance(Type? type, DialogParameters parameters, object content, IJSObjectReference? previouslyFocusedElement)
1012
{
1113
ContentType = type;
1214
Parameters = parameters;
1315
Content = content;
1416
Id = Parameters.Id ?? Identifier.NewId();
17+
PreviouslyFocusedElement = previouslyFocusedElement;
1518
}
1619

1720
public string Id { get; }
@@ -22,6 +25,8 @@ public DialogInstance(Type? type, DialogParameters parameters, object content)
2225

2326
public DialogParameters Parameters { get; internal set; }
2427

28+
internal IJSObjectReference? PreviouslyFocusedElement { get; }
29+
2530
internal Dictionary<string, object>? GetParameterDictionary()
2631
{
2732
if (Content is null)

tests/Core/Dialog/FluentDialogServiceTests.razor

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,10 @@
88

99
public FluentDialogServiceTests()
1010
{
11+
JSInterop.Mode = JSRuntimeMode.Loose;
1112
Services.AddSingleton(LibraryConfiguration);
13+
var dialogUtilsModule = JSInterop.SetupModule("./_content/Microsoft.FluentUI.AspNetCore.Components/Components/Dialog/FluentDialogProvider.razor.js");
14+
dialogUtilsModule.SetupModule("getActiveElement", _ => true);
1215
}
1316

1417
[Fact]

0 commit comments

Comments
 (0)