From f1ac5eaad2b9bdcf73c9074bd6e7f6cdaad5d09f Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 23:49:49 +0200 Subject: [PATCH] Back closes what is open On a phone the Back button left the page under an open menu, sheet or dialog. An open overlay now says so (new IDOverlayHost, cascaded by MainLayout; OverlayService keeps them newest last): DModal and DDropdown through the cascade, so the D components stay free of app services, and the nav menu, PhoneSheet and the full-screen globe through the service. MainLayout's location-changing handler lets Back close the newest overlay instead of leaving the page; a link followed or the app's own navigation (signing out after its question, say) closes them all and goes on. Blazor can't tell Back from the app's navigation, so a capture-phase popstate listener in main.js marks Back (AppStatusService.TookBack). No history entries are pushed. Phone flow: Back closes the menu, a card's More sheet, the composer and the full-screen globe one after another without leaving the feed, and with nothing open goes back. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- Components/Daisy/Actions/DDropdown.razor | 25 +++++++++ Components/Daisy/Actions/DModal.razor | 24 +++++++++ Components/Daisy/IDOverlayHost.cs | 11 ++++ Components/Feed/GlobePanel.razor | 17 ++++++- Components/PhoneSheet.razor | 22 ++++++++ Program.cs | 1 + Services/AppStatusService.cs | 3 ++ Services/OverlayService.cs | 64 ++++++++++++++++++++++++ Shared/MainLayout.razor | 52 +++++++++++++++---- Shared/NavMenu.razor | 33 +++++++++--- tests/e2e/Mobile/FlowTests.cs | 45 +++++++++++++++++ wwwroot/main.js | 10 ++++ 12 files changed, 290 insertions(+), 17 deletions(-) create mode 100644 Components/Daisy/IDOverlayHost.cs create mode 100644 Services/OverlayService.cs diff --git a/Components/Daisy/Actions/DDropdown.razor b/Components/Daisy/Actions/DDropdown.razor index 79b4b72..45d8764 100644 --- a/Components/Daisy/Actions/DDropdown.razor +++ b/Components/Daisy/Actions/DDropdown.razor @@ -1,4 +1,5 @@ @inherits DComponentBase +@implements IDisposable @* daisyUI Dropdown: https://daisyui.com/components/dropdown/ . The open state lives in C# (@bind-IsOpen): dropdown-open shows the content and dropdown-close keeps the focus-within rule from showing it while closed. @@ -42,8 +43,12 @@ + "max-md:overflow-y-auto max-md:overscroll-contain max-md:rounded-b-none max-md:translate-none max-md:scale-100 " + "max-md:p-3 max-md:pb-[max(0.75rem,env(safe-area-inset-bottom))]"; + /// Where it says it is open, so the Back button closes it (cascaded by the app; none, none said). + [CascadingParameter] IDOverlayHost Overlays { get; set; } + ElementReference trigger; ElementReference content; + IDisposable overlay; bool focusContent; bool focusTrigger; @@ -65,6 +70,25 @@ _ => null }; + protected override void OnParametersSet() => TrackOverlay(); + + void TrackOverlay() + { + if (IsOpen && overlay is null) + overlay = Overlays?.Open(() => InvokeAsync(async () => + { + await SetOpen(false, true); + StateHasChanged(); + })); + else if (!IsOpen && overlay is not null) + { + overlay.Dispose(); + overlay = null; + } + } + + public void Dispose() => overlay?.Dispose(); + protected override async Task OnAfterRenderAsync(bool firstRender) { if (focusContent) @@ -93,6 +117,7 @@ focusContent = isOpen && !IsOpen; focusTrigger = !isOpen && IsOpen && giveFocusBack; IsOpen = isOpen; + TrackOverlay(); await IsOpenChanged.InvokeAsync(isOpen); } } diff --git a/Components/Daisy/Actions/DModal.razor b/Components/Daisy/Actions/DModal.razor index fcd6cce..f2746b2 100644 --- a/Components/Daisy/Actions/DModal.razor +++ b/Components/Daisy/Actions/DModal.razor @@ -1,4 +1,5 @@ @inherits DComponentBase +@implements IDisposable @* daisyUI Modal: https://daisyui.com/components/modal/ . Always rendered; the open state lives in C# (@bind-IsOpen) and shows as modal-open. On opening, the box takes the focus (Blazor's FocusAsync) so that Escape, handled on the @@ -58,7 +59,11 @@ [Parameter] public bool ShowClose { get; set; } = true; [Parameter] public string BoxClass { get; set; } + /// Where it says it is open, so the Back button closes it (cascaded by the app; none, none said). + [CascadingParameter] IDOverlayHost Overlays { get; set; } + readonly string uid = "d" + Guid.NewGuid().ToString("N")[..8]; + IDisposable overlay; ElementReference box; bool wasOpen; bool focusPending; @@ -80,8 +85,26 @@ if (IsOpen && !wasOpen) focusPending = true; wasOpen = IsOpen; + TrackOverlay(); } + void TrackOverlay() + { + if (IsOpen && overlay is null) + overlay = Overlays?.Open(() => InvokeAsync(async () => + { + await SetOpen(false); + StateHasChanged(); + })); + else if (!IsOpen && overlay is not null) + { + overlay.Dispose(); + overlay = null; + } + } + + public void Dispose() => overlay?.Dispose(); + protected override async Task OnAfterRenderAsync(bool firstRender) { if (!focusPending) @@ -98,6 +121,7 @@ { IsOpen = isOpen; wasOpen = isOpen; + TrackOverlay(); await IsOpenChanged.InvokeAsync(isOpen); } } diff --git a/Components/Daisy/IDOverlayHost.cs b/Components/Daisy/IDOverlayHost.cs new file mode 100644 index 0000000..6cd1e79 --- /dev/null +++ b/Components/Daisy/IDOverlayHost.cs @@ -0,0 +1,11 @@ +namespace decePubClient.Components.Daisy; + +/// +/// Where an open overlay (a dialog, a menu, a sheet) says it is open, so that the phone's Back button closes it instead +/// of leaving the page: Open registers how to close it and returns what ends the registration once it is closed. The +/// app cascades one (OverlayService, from MainLayout); a component without one simply doesn't register. +/// +public interface IDOverlayHost +{ + IDisposable Open(Func close); +} diff --git a/Components/Feed/GlobePanel.razor b/Components/Feed/GlobePanel.razor index 01b085d..afabf9f 100644 --- a/Components/Feed/GlobePanel.razor +++ b/Components/Feed/GlobePanel.razor @@ -82,8 +82,11 @@ [Parameter] public bool Expanded { get; set; } [Parameter] public EventCallback ExpandedChanged { get; set; } + [Inject] OverlayService Overlays { get; set; } + ElementReference closeButton; ElementReference expandButton; + IDisposable overlay; bool focusClose; bool focusExpand; @@ -115,7 +118,18 @@ protected override void OnInitialized() => IsLoading = false; - protected override void OnParametersSet() => postsHandedOver = true; + protected override void OnParametersSet() + { + postsHandedOver = true; + // full screen, the Back button closes it + if (Expanded && overlay is null) + overlay = Overlays.Open(() => InvokeAsync(Close)); + else if (!Expanded && overlay is not null) + { + overlay.Dispose(); + overlay = null; + } + } protected override async Task OnAfterRenderAsync(bool firstRender) { @@ -323,6 +337,7 @@ public override async ValueTask DisposeAsync() { + overlay?.Dispose(); await cancellation.CancelAsync(); cancellation.Dispose(); statsTimer?.Dispose(); diff --git a/Components/PhoneSheet.razor b/Components/PhoneSheet.razor index 56ad5e6..f29ef60 100644 --- a/Components/PhoneSheet.razor +++ b/Components/PhoneSheet.razor @@ -29,8 +29,30 @@ [Parameter] public RenderFragment ChildContent { get; set; } [Parameter] public string Class { get; set; } + [Inject] OverlayService Overlays { get; set; } + + IDisposable overlay; + protected override void OnInitialized() => IsLoading = false; + // open, the Back button closes it + protected override void OnParametersSet() + { + if (IsOpen && overlay is null) + overlay = Overlays.Open(() => InvokeAsync(Close)); + else if (!IsOpen && overlay is not null) + { + overlay.Dispose(); + overlay = null; + } + } + + public override async ValueTask DisposeAsync() + { + overlay?.Dispose(); + await base.DisposeAsync(); + } + Task Close() => OnClose.InvokeAsync(); Task OnKeyDown(KeyboardEventArgs e) => e.Key == "Escape" ? Close() : Task.CompletedTask; diff --git a/Program.cs b/Program.cs index 533799d..bd21a8e 100644 --- a/Program.cs +++ b/Program.cs @@ -51,6 +51,7 @@ builder.Services.AddOptions() .AddScoped() .AddScoped() .AddScoped() + .AddScoped() .AddScoped() .AddScoped() .AddScoped() diff --git a/Services/AppStatusService.cs b/Services/AppStatusService.cs index 8dcd26c..4c2be02 100644 --- a/Services/AppStatusService.cs +++ b/Services/AppStatusService.cs @@ -42,6 +42,9 @@ public class AppStatusService(IJSRuntime jsRuntime) public bool IsMobileMedia() => JsSyncRuntime.Invoke("isMobileMedia"); + // whether the navigation being decided came from the Back (or Forward) button + public bool TookBack() => JsSyncRuntime.Invoke("tookBack"); + // whether the pointer can hover (a mouse); a touch screen can't, so what shows on hover there opens on a tap public bool CanHover() => JsSyncRuntime.Invoke("canHover"); diff --git a/Services/OverlayService.cs b/Services/OverlayService.cs new file mode 100644 index 0000000..abbfbe2 --- /dev/null +++ b/Services/OverlayService.cs @@ -0,0 +1,64 @@ +using decePubClient.Components.Daisy; + +namespace decePubClient.Services +{ + /// + /// The overlays open now, newest last (IDOverlayHost): MainLayout closes the newest when the Back button is pressed + /// while one is open, and all of them when the app goes elsewhere. + /// + public class OverlayService : IDOverlayHost + { + readonly List open = []; + + public bool HasOpen + { + get + { + lock (open) + return open.Count > 0; + } + } + + public IDisposable Open(Func close) + { + var entry = new Entry(this, close); + lock (open) + open.Add(entry); + return entry; + } + + public async Task CloseTop(CancellationToken cancellationToken = default) + { + Entry top; + lock (open) + top = open.LastOrDefault(); + if (top is null) + return; + top.Dispose(); + await top.Close(); + } + + public async Task CloseAll(CancellationToken cancellationToken = default) + { + Entry[] all; + lock (open) + { + all = [.. open]; + open.Clear(); + } + foreach (var entry in all.Reverse()) + await entry.Close(); + } + + sealed class Entry(OverlayService owner, Func close) : IDisposable + { + public Func Close { get; } = close; + + public void Dispose() + { + lock (owner.open) + owner.open.Remove(this); + } + } + } +} diff --git a/Shared/MainLayout.razor b/Shared/MainLayout.razor index 2e73370..524718f 100644 --- a/Shared/MainLayout.razor +++ b/Shared/MainLayout.razor @@ -1,15 +1,47 @@ -@inherits LayoutComponentBase +@inherits LayoutComponentBase +@implements IDisposable -
- +@* The page and its menu. The overlays it holds (dialogs, menus, sheets: IDOverlayHost, cascaded) close with the phone's + Back button: while one is open, Back closes the newest instead of leaving the page; a link followed or the app's own + navigation closes them all and goes on. *@ + +
+ -
-
- @Body +
+
+ @Body +
-
- - - \ No newline at end of file + + + + + +@code { + [Inject] OverlayService Overlays { get; set; } + [Inject] NavigationManager Navigation { get; set; } + [Inject] AppStatusService Status { get; set; } + + IDisposable changing; + + protected override void OnInitialized() => changing = Navigation.RegisterLocationChangingHandler(OnLocationChanging); + + async ValueTask OnLocationChanging(LocationChangingContext context) + { + var back = !context.IsNavigationIntercepted && Status.TookBack(); + if (!Overlays.HasOpen) + return; + if (back) + { + context.PreventNavigation(); + await Overlays.CloseTop(context.CancellationToken); + return; + } + await Overlays.CloseAll(context.CancellationToken); + } + + public void Dispose() => changing?.Dispose(); +} diff --git a/Shared/NavMenu.razor b/Shared/NavMenu.razor index 47292e1..4fcf721 100644 --- a/Shared/NavMenu.razor +++ b/Shared/NavMenu.razor @@ -141,7 +141,27 @@ @code { [Inject] ConfirmService Confirm { get; set; } + [Inject] OverlayService Overlays { get; set; } + bool isMenuOpen; + IDisposable overlay; + + // open, the Back button closes it + void SetMenu(bool open) + { + isMenuOpen = open; + if (open && overlay is null) + overlay = Overlays.Open(() => InvokeAsync(() => + { + SetMenu(false); + StateHasChanged(); + })); + else if (!open && overlay is not null) + { + overlay.Dispose(); + overlay = null; + } + } bool isPersonaMenuOpen; string PersonaBackground => CascadingState.Persona?.Header is { Length: > 0 } header && !header.Contains("/missing-") @@ -162,37 +182,38 @@ { if (!isMenuOpen) return; - isMenuOpen = false; + SetMenu(false); InvokeAsync(StateHasChanged); } - void CloseMenu() => isMenuOpen = false; + void CloseMenu() => SetMenu(false); void OnKeyDown(KeyboardEventArgs e) { if (e.Key == "Escape") - isMenuOpen = false; + SetMenu(false); } public override async ValueTask DisposeAsync() { Navigation.LocationChanged -= OnLocationChanged; + overlay?.Dispose(); await base.DisposeAsync(); } async Task SwitchPersona(string personaId) { - isMenuOpen = false; + SetMenu(false); var result = await CascadingState.SwitchPersona(personaId); if (result.IsInvalid) Toast.AddResponseError(result); } - private void ToggleNavMenu() => isMenuOpen = !isMenuOpen; + private void ToggleNavMenu() => SetMenu(!isMenuOpen); async Task Logout() { - isMenuOpen = false; + SetMenu(false); if (!await Confirm.Ask(Localizer["Log out?"], Localizer["Every persona of this login is signed out on this device."], Localizer["Logout"], true)) return; Navigation.NavigateTo("logout"); diff --git a/tests/e2e/Mobile/FlowTests.cs b/tests/e2e/Mobile/FlowTests.cs index 9da1b2b..6da7411 100644 --- a/tests/e2e/Mobile/FlowTests.cs +++ b/tests/e2e/Mobile/FlowTests.cs @@ -310,6 +310,51 @@ namespace decePubClient.E2E.Mobile }); } + // the Back button closes what is open, one at a time, and stays on the page + [Fact] + public async Task Back_closes_what_is_open() + { + await using var session = await SignIn.As(browser, Persona(), Phone); + await Flow(session, "back", async page => + { + var home = page.Url; + // a page to go back to, so a Back that isn't held would leave the feed + await page.Locator("[data-nav-toggle]").TapAsync(); + await page.Locator("[data-nav-panel] a[href='explore']").TapAsync(); + await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore"); + await page.Locator("[data-nav-toggle]").TapAsync(); + await page.Locator("[data-nav-panel] a[href='/']").First.TapAsync(); + await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/"); + await page.Locator("#feed-scroll article[data-uri]").First.WaitForAsync(); + + async Task BackCloses(ILocator open, string what) + { + await Assertions.Expect(open).ToBeVisibleAsync(); + await page.GoBackAsync(); + await Assertions.Expect(open).ToBeHiddenAsync(new() { Timeout = 5000 }); + Assert.True(new Uri(page.Url).AbsolutePath == "/", $"Back after the {what} left the feed for {page.Url}"); + } + + await page.Locator("[data-nav-toggle]").TapAsync(); + await BackCloses(page.Locator("[data-nav-panel]"), "menu"); + + var card = Card(page, "footer [title='Other']"); + await card.Locator("footer [title='Other'] > [role=button]").TapAsync(); + await BackCloses(card.Locator(".dropdown-open > [data-sheet]"), "More sheet"); + + await page.Locator("[data-compose-open]").TapAsync(); + await BackCloses(page.Locator("[data-sheet]:has(textarea[data-compose])"), "composer"); + + await page.Locator("button[title='Globe']").TapAsync(); + await page.Locator("[data-globe-expand]").TapAsync(); + await BackCloses(page.Locator("aside[role=dialog]"), "full-screen globe"); + + // with nothing open, Back leaves the feed as before + await page.GoBackAsync(); + await page.WaitForURLAsync(url => new Uri(url).AbsolutePath == "/explore", new() { Timeout = 10000 }); + }); + } + static async Task Latest(Pasture.Persona persona) => (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault(); } diff --git a/wwwroot/main.js b/wwwroot/main.js index b09025d..6b5dfbb 100644 --- a/wwwroot/main.js +++ b/wwwroot/main.js @@ -140,6 +140,16 @@ document.addEventListener("click", event => { window.logFromJs(e.message, "openThread") } }) +// The Back button (a popstate) as opposed to the app's own navigation: Blazor's location-changing handlers can't tell +// them apart, and MainLayout lets Back close an open overlay but lets the app's navigation through. Registered for the +// capture phase, so it runs before Blazor's own listener. +let backAt = 0 +window.addEventListener("popstate", () => backAt = Date.now(), { capture: true }) +window.tookBack = () => { + const back = Date.now() - backAt < 1000 + backAt = 0 + return back +} window.canHover = () => { try { return window.matchMedia("(hover: hover)").matches