From ff899132f89a9ac08c1689a53be00a8385a2603e Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 20:30:49 -0400 Subject: [PATCH 1/2] Let people turn off single-key shortcuts and test the keyboard paths properly Closes #2626. --- .../Components/Pages/ChangeSetView.razor | 11 +- .../Components/Pages/Desk.razor | 1 + .../Components/Review/ShortcutHelp.razor | 4 + src/FluentCMS.Admin/wwwroot/js/admin.js | 44 ++++++- .../Browser/KeyboardReviewTests.cs | 109 ++++++++++++++++++ .../Admin/ChangeSetViewTests.cs | 3 +- tests/FluentCMS.Tests/Admin/DeskTests.cs | 5 +- 7 files changed, 170 insertions(+), 7 deletions(-) diff --git a/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor b/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor index 90bfed11b..6eec3ba9e 100644 --- a/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor +++ b/src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor @@ -375,7 +375,9 @@ private IReadOnlyList Shortcuts => [ new(["J", "K"], L["Move to the next or previous item"]), - new(["Enter"], L["Fold or unfold the item that has focus"]), + new(["↓", "↑"], L["Move between items while one has focus"]), + new(["Home", "End"], L["Move to the first or last item while one has focus"]), + new(["Enter", "Space"], L["Fold or unfold the item that has focus"]), new(["S"], L["Submit an open set for checks"]), new(["A"], L["Approve the set and move to the next one"]), new(["R"], L["Reject the set with a reason"]), @@ -541,6 +543,8 @@ return null; } + // The next set is the one that followed this one before the decision, which may take this one out of the queue. + var next = _next; if (form?.Seen is null || form.Seen != _seen || !offeredIn.Contains(_set.State)) { _refusal = L["The set has moved on since this page was shown. Review it as it is now."]; @@ -563,6 +567,11 @@ } await LoadAsync(); + if (result.IsSuccess) + { + _next = next; + } + return result.IsSuccess ? result.Value : null; } diff --git a/src/FluentCMS.Admin/Components/Pages/Desk.razor b/src/FluentCMS.Admin/Components/Pages/Desk.razor index d48d98185..3a26a810e 100644 --- a/src/FluentCMS.Admin/Components/Pages/Desk.razor +++ b/src/FluentCMS.Admin/Components/Pages/Desk.razor @@ -243,6 +243,7 @@ new(["1–6"], L["Switch to a queue by its number"]), new(["J", "K"], L["Move to the next or previous row"]), new(["↓", "↑"], L["Move between rows while one has focus"]), + new(["Home", "End"], L["Move to the first or last row while one has focus"]), new(["Enter"], L["Open the row's change set"]), new(["?"], L["Show these shortcuts"]), ]; diff --git a/src/FluentCMS.Admin/Components/Review/ShortcutHelp.razor b/src/FluentCMS.Admin/Components/Review/ShortcutHelp.razor index 2431542bd..e6275579a 100644 --- a/src/FluentCMS.Admin/Components/Review/ShortcutHelp.razor +++ b/src/FluentCMS.Admin/Components/Review/ShortcutHelp.razor @@ -10,6 +10,10 @@ @L["Keyboard shortcuts"] @L["Keys work anywhere on the page except while you type in a field."] + @* WCAG 2.1 SC 2.1.4: keys without Control, Alt or Meta can be turned off. admin.js keeps the choice in this browser. *@ + + +
@foreach (var shortcut in Shortcuts) { diff --git a/src/FluentCMS.Admin/wwwroot/js/admin.js b/src/FluentCMS.Admin/wwwroot/js/admin.js index 3e1d7be1a..27e4cf13f 100644 --- a/src/FluentCMS.Admin/wwwroot/js/admin.js +++ b/src/FluentCMS.Admin/wwwroot/js/admin.js @@ -328,7 +328,9 @@ const observer = new MutationObserver((mutations) => { const { input } = commandParts(mutation.target); if (input) inputs.add(input); for (const node of mutation.addedNodes) { - if (node instanceof Element) node.querySelectorAll('[data-ui="command-input"]').forEach((added) => inputs.add(added)); + if (!(node instanceof Element)) continue; + node.querySelectorAll('[data-ui="command-input"]').forEach((added) => inputs.add(added)); + showSingleKeys(node); } } inputs.forEach(filterCommand); @@ -343,6 +345,34 @@ const observer = new MutationObserver((mutations) => { // shortcuts inside it apply. const modifiers = { control: "ctrlKey", alt: "altKey", shift: "shiftKey", meta: "metaKey" }; + +// WCAG 2.1 SC 2.1.4: a person can turn off every shortcut without Control, Alt or Meta, in the shortcuts dialog +// (ShortcutHelp.razor). The choice is kept in this browser, and storage that refuses leaves them on. +const singleKeysStorage = "fluentcms-single-key-shortcuts"; + +function singleKeysOn() { + try { + return localStorage.getItem(singleKeysStorage) !== "off"; + } catch { + return true; + } +} + +function chooseSingleKeys(on) { + try { + if (on) localStorage.removeItem(singleKeysStorage); + else localStorage.setItem(singleKeysStorage, "off"); + } catch { + // The choice lasts for this page only. + } +} + +// The page renders the switch on; it shows the stored choice, again whenever Blazor renders it anew. +function showSingleKeys(root) { + const toggles = [...(root.querySelectorAll?.('[data-ui="single-key-shortcuts"]') ?? [])]; + if (root.matches?.('[data-ui="single-key-shortcuts"]')) toggles.push(root); + toggles.forEach((toggle) => { toggle.checked = singleKeysOn(); }); +} const editable = (element) => element.isContentEditable || element.matches('input:not([type="checkbox"], [type="radio"], [type="button"], [type="submit"], [type="reset"]), textarea, select'); const shown = (element) => (element.checkVisibility ? element.checkVisibility() : visible(element)) && !element.closest("[inert]"); @@ -364,7 +394,7 @@ function matchesShortcut(event, shortcut) { function onShortcutKeydown(event) { if (event.defaultPrevented || event.isComposing || event.repeat || ["Control", "Alt", "Shift", "Meta"].includes(event.key)) return false; const chord = event.ctrlKey || event.altKey || event.metaKey; - if (!chord && (editable(event.target) || event.target.closest('[data-ui="menu"]'))) return false; + if (!chord && (!singleKeysOn() || editable(event.target) || event.target.closest('[data-ui="menu"]'))) return false; const modal = document.querySelector("dialog:modal"); for (const element of document.querySelectorAll("[aria-keyshortcuts]")) { if ((modal && !modal.contains(element)) || element.matches(":disabled") || !shown(element)) continue; @@ -390,11 +420,12 @@ function onRoveKeydown(event) { if (event.ctrlKey || event.altKey || event.metaKey || event.isComposing) return false; const target = event.target; const inside = target.closest('[data-ui="rove"]'); + // J and K also match their physical keys, as the other letters do, so they work on any layout. const key = event.key.toLowerCase(); - const step = key === "j" ? 1 : key === "k" ? -1 : 0; + const step = key === "j" || event.code === "KeyJ" ? 1 : key === "k" || event.code === "KeyK" ? -1 : 0; if (step !== 0) { - if (event.shiftKey || editable(target) || target.closest('[data-ui="menu"]')) return false; + if (!singleKeysOn() || event.shiftKey || editable(target) || target.closest('[data-ui="menu"]')) return false; const modal = document.querySelector("dialog:modal"); const list = inside ?? [...document.querySelectorAll('[data-ui="rove"]')].find((candidate) => (!modal || modal.contains(candidate)) && roveItems(candidate).length > 0); if (!list || (modal && !modal.contains(list))) return false; @@ -536,6 +567,10 @@ document.addEventListener("keydown", (event) => { if (trap) trapFocus(event, trap); }); +document.addEventListener("change", (event) => { + if (event.target.matches?.('[data-ui="single-key-shortcuts"]')) chooseSingleKeys(event.target.checked); +}); + document.addEventListener("input", (event) => { if (event.target.matches?.('[data-ui="command-input"]')) filterCommand(event.target); }); @@ -549,4 +584,5 @@ document.addEventListener("toggle", onMenuToggle, true); document.addEventListener("toggle", onPopoverToggle, true); document.addEventListener("components-reconnect-state-changed", onReconnectStateChanged, true); document.querySelectorAll('[data-ui="command-input"]').forEach(filterCommand); +showSingleKeys(document); observer.observe(document.body, { childList: true, subtree: true }); diff --git a/tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs b/tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs index f6d1f0db4..97dc4012f 100644 --- a/tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs +++ b/tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs @@ -102,6 +102,115 @@ public async Task Shortcuts_wait_while_a_field_has_focus_and_the_help_lists_them await page.WaitForURLAsync(new Regex("/\\?queue=awaiting-me$")); } + // WCAG 2.1 SC 2.1.4: single-key shortcuts turn off in the help, and stay off in this browser after a reload. + [BrowserFact] + public async Task Single_key_shortcuts_turn_off_in_the_help_and_stay_off_after_a_reload() + { + await using var session = await BrowserSession.StartAsync(); + var set = await AwaitingReviewAsync(session, "product", 2); + var page = session.Page; + + await session.SignInWithKeyboardAsync(); + await page.GotoAsync($"{session.BaseUrl}/sets/{set.Id}?queue=awaiting-me"); + await Assertions.Expect(page.Locator("[data-testid=change-set-items][data-interactive=true]")).ToBeVisibleAsync(); + await page.Keyboard.PressAsync("?"); + var toggle = page.Locator("[data-ui=single-key-shortcuts]"); + await Assertions.Expect(toggle).ToBeCheckedAsync(); + await toggle.FocusAsync(); + await page.Keyboard.PressAsync("Space"); + await Assertions.Expect(toggle).Not.ToBeCheckedAsync(); + await page.Keyboard.PressAsync("Escape"); + + await page.Keyboard.PressAsync("j"); + await page.Keyboard.PressAsync("a"); + Assert.Equal(-1, await page.EvaluateAsync("[...document.querySelectorAll('[data-slot=diff-view-item-summary]')].indexOf(document.activeElement)")); + Assert.Equal(ChangeSetState.AwaitingReview, await StateAsync(session, set.Id)); + + await page.ReloadAsync(); + await Assertions.Expect(page.Locator("[data-action=approve]")).ToBeVisibleAsync(); + await page.Keyboard.PressAsync("a"); + await page.Keyboard.PressAsync("?"); + Assert.Equal(ChangeSetState.AwaitingReview, await StateAsync(session, set.Id)); + await Assertions.Expect(page.GetByTestId("shortcut-list")).Not.ToBeVisibleAsync(); + await page.GetByTestId("shortcut-help").ClickAsync(); + await Assertions.Expect(page.Locator("[data-ui=single-key-shortcuts]")).Not.ToBeCheckedAsync(); + } + + [BrowserFact] + public async Task Arrow_keys_home_and_end_move_through_desk_rows_and_set_items() + { + await using var session = await BrowserSession.StartAsync(); + var large = await AwaitingReviewAsync(session, "large", 5); + await AwaitingReviewAsync(session, "second", 1); + await AwaitingReviewAsync(session, "third", 1); + var page = session.Page; + + await session.SignInWithKeyboardAsync(); + var rows = "[...document.querySelectorAll('[data-change-set]')].findIndex(row => row.contains(document.activeElement))"; + await page.Keyboard.PressAsync("j"); + Assert.Equal(0, await page.EvaluateAsync(rows)); + await page.Keyboard.PressAsync("ArrowDown"); + Assert.Equal(1, await page.EvaluateAsync(rows)); + await page.Keyboard.PressAsync("End"); + Assert.Equal(2, await page.EvaluateAsync(rows)); + await page.Keyboard.PressAsync("ArrowUp"); + Assert.Equal(1, await page.EvaluateAsync(rows)); + await page.Keyboard.PressAsync("Home"); + Assert.Equal(0, await page.EvaluateAsync(rows)); + + await page.GotoAsync($"{session.BaseUrl}/sets/{large.Id}"); + await Assertions.Expect(page.Locator("[data-testid=change-set-items][data-interactive=true]")).ToBeVisibleAsync(); + var items = "[...document.querySelectorAll('[data-slot=diff-view-item-summary]')].indexOf(document.activeElement)"; + await page.Keyboard.PressAsync("j"); + Assert.Equal(0, await page.EvaluateAsync(items)); + await page.Keyboard.PressAsync("ArrowDown"); + Assert.Equal(1, await page.EvaluateAsync(items)); + await page.Keyboard.PressAsync("End"); + Assert.Equal(4, await page.EvaluateAsync(items)); + await page.Keyboard.PressAsync("Home"); + Assert.Equal(0, await page.EvaluateAsync(items)); + } + + // Outside any dialog, a key typed into a field types: B in a generated form's title does not go back. + [BrowserFact] + public async Task A_shortcut_key_typed_into_a_form_field_types_and_does_not_act() + { + await using var session = await BrowserSession.StartAsync(); + await session.Host.AsUserAsync(session.OwnerId, async types => (await types.CreateAsync( + new NewContentType("Note", "note", new ContentTypeDefinition([new FieldDefinition("title", "text")])), TestChangeSetTargets.ContentModel)).Value); + var page = session.Page; + + await session.SignInWithKeyboardAsync(); + await page.GotoAsync($"{session.BaseUrl}/content/note/new"); + await page.Locator("input[name='Form.Values[title]']").FocusAsync(); + await page.Keyboard.TypeAsync("Big bold jab"); + + Assert.Equal("Big bold jab", await page.Locator("input[name='Form.Values[title]']").InputValueAsync()); + Assert.EndsWith("/content/note/new", page.Url); + } + + // After a decision the next set is the one that followed this one, not the head of the queue the reviewer skipped past. + [BrowserFact] + public async Task Skipping_a_set_and_approving_the_next_lands_on_the_set_after_it() + { + await using var session = await BrowserSession.StartAsync(); + var first = await AwaitingReviewAsync(session, "first", 1); + var second = await AwaitingReviewAsync(session, "second", 1); + var third = await AwaitingReviewAsync(session, "third", 1); + var page = session.Page; + + await session.SignInWithKeyboardAsync(); + await page.GotoAsync($"{session.BaseUrl}/sets/{first.Id}?queue=awaiting-me"); + await Assertions.Expect(page.Locator("[data-action=approve]")).ToBeVisibleAsync(); + await page.Keyboard.PressAsync("n"); + await page.WaitForURLAsync(new Regex($"/sets/{second.Id}\\?queue=awaiting-me$")); + await Assertions.Expect(page.Locator($"[data-testid=next-in-queue][href*='{third.Id}']")).ToBeAttachedAsync(); + await page.Keyboard.PressAsync("a"); + + await page.WaitForURLAsync(new Regex($"/sets/{third.Id}\\?queue=awaiting-me&done=approved$")); + Assert.Equal((ChangeSetState.AwaitingReview, ChangeSetState.Approved), (await StateAsync(session, first.Id), await StateAsync(session, second.Id))); + } + private static async Task FocusedTextAsync(IPage page) { return (await page.EvaluateAsync("document.activeElement?.textContent"))?.Trim(); diff --git a/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs b/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs index e69ccac2e..fa4dc7fef 100644 --- a/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs +++ b/tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs @@ -172,7 +172,8 @@ public void The_actions_have_their_keys_and_a_set_opened_from_a_queue_links_back Assert.Equal($"sets/{next}?queue=awaiting-anyone", cut.Find("[data-testid=next-in-queue]").GetAttribute("href")); Assert.Equal("N", cut.Find("[data-testid=next-in-queue]").GetAttribute("aria-keyshortcuts")); Assert.Equal("The previous set was approved.", cut.Find("[data-testid=review-done]").TextContent); - Assert.Equal(9, cut.FindAll("[data-testid=shortcut-list] dt").Count); + Assert.Equal(11, cut.FindAll("[data-testid=shortcut-list] dt").Count); + Assert.Contains("Space", cut.Find("[data-testid=shortcut-list]").TextContent); } [Fact] diff --git a/tests/FluentCMS.Tests/Admin/DeskTests.cs b/tests/FluentCMS.Tests/Admin/DeskTests.cs index c23822c4c..bc85195f2 100644 --- a/tests/FluentCMS.Tests/Admin/DeskTests.cs +++ b/tests/FluentCMS.Tests/Admin/DeskTests.cs @@ -72,7 +72,10 @@ public void Rows_rove_with_only_the_first_in_the_tab_order_and_queues_have_numbe Assert.Equal([null, "-1"], links.Select(link => link.GetAttribute("tabindex"))); Assert.Equal(["1", "2", "3", "4", "5", "6"], cut.FindAll("[data-queue]").Select(link => link.GetAttribute("aria-keyshortcuts"))); Assert.Equal("?", cut.Find("[data-testid=shortcut-help]").GetAttribute("aria-keyshortcuts")); - Assert.Equal(5, cut.FindAll("[data-testid=shortcut-list] dt").Count); + Assert.Equal(6, cut.FindAll("[data-testid=shortcut-list] dt").Count); + Assert.Equal("Home End", string.Join(" ", cut.FindAll("[data-testid=shortcut-list] dt")[3].QuerySelectorAll("kbd").Select(key => key.TextContent))); + Assert.Equal("single-key-shortcuts", cut.Find("[role=switch]").GetAttribute("data-ui")); + Assert.True(cut.Find("[role=switch]").HasAttribute("checked")); } [Theory] From df0399a28277a68bfd5884187692989b35445d88 Mon Sep 17 00:00:00 2001 From: Amir Pournasserian Date: Mon, 19 Oct 2026 23:02:46 -0400 Subject: [PATCH 2/2] Show the stored shortcut choice after enhanced navigation, keep it for the page when storage refuses, and test the reload properly --- src/FluentCMS.Admin/wwwroot/js/admin.js | 18 ++++++++++++++- .../Browser/KeyboardReviewTests.cs | 23 ++++++++++++++++--- 2 files changed, 37 insertions(+), 4 deletions(-) diff --git a/src/FluentCMS.Admin/wwwroot/js/admin.js b/src/FluentCMS.Admin/wwwroot/js/admin.js index 18cf6b139..e78e810f2 100644 --- a/src/FluentCMS.Admin/wwwroot/js/admin.js +++ b/src/FluentCMS.Admin/wwwroot/js/admin.js @@ -350,7 +350,11 @@ const modifiers = { control: "ctrlKey", alt: "altKey", shift: "shiftKey", meta: // (ShortcutHelp.razor). The choice is kept in this browser, and storage that refuses leaves them on. const singleKeysStorage = "fluentcms-single-key-shortcuts"; +// The choice made on this page when storage refuses to keep it. +let singleKeysChosen; + function singleKeysOn() { + if (singleKeysChosen !== undefined) return singleKeysChosen; try { return localStorage.getItem(singleKeysStorage) !== "off"; } catch { @@ -362,8 +366,9 @@ function chooseSingleKeys(on) { try { if (on) localStorage.removeItem(singleKeysStorage); else localStorage.setItem(singleKeysStorage, "off"); + singleKeysChosen = undefined; } catch { - // The choice lasts for this page only. + singleKeysChosen = on; } } @@ -627,6 +632,17 @@ document.addEventListener("change", (event) => { if (event.target.matches?.('[data-ui="single-key-shortcuts"]')) chooseSingleKeys(event.target.checked); }); +// Enhanced navigation keeps the switch and sets it back to the page's "on", so the stored choice is shown again after each one, +// and again whenever the switch takes focus. +try { + window.Blazor?.addEventListener?.("enhancedload", () => showSingleKeys(document)); +} catch { + // A page without enhanced navigation has nothing to listen to; focus still shows the choice. +} +document.addEventListener("focusin", (event) => { + if (event.target.matches?.('[data-ui="single-key-shortcuts"]')) showSingleKeys(event.target); +}); + document.addEventListener("input", (event) => { if (event.target.matches?.('[data-ui="command-input"]')) filterCommand(event.target); }); diff --git a/tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs b/tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs index 97dc4012f..e9e456399 100644 --- a/tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs +++ b/tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs @@ -127,11 +127,22 @@ public async Task Single_key_shortcuts_turn_off_in_the_help_and_stay_off_after_a Assert.Equal(ChangeSetState.AwaitingReview, await StateAsync(session, set.Id)); await page.ReloadAsync(); - await Assertions.Expect(page.Locator("[data-action=approve]")).ToBeVisibleAsync(); - await page.Keyboard.PressAsync("a"); + await Assertions.Expect(page.Locator("[data-testid=change-set-items][data-interactive=true]")).ToBeVisibleAsync(); + await page.Keyboard.PressAsync("j"); await page.Keyboard.PressAsync("?"); - Assert.Equal(ChangeSetState.AwaitingReview, await StateAsync(session, set.Id)); + Assert.Equal(-1, await page.EvaluateAsync("[...document.querySelectorAll('[data-slot=diff-view-item-summary]')].indexOf(document.activeElement)")); await Assertions.Expect(page.GetByTestId("shortcut-list")).Not.ToBeVisibleAsync(); + await page.Keyboard.PressAsync("a"); + await page.WaitForTimeoutAsync(1500); + Assert.EndsWith($"/sets/{set.Id}?queue=awaiting-me", page.Url); + Assert.Equal(ChangeSetState.AwaitingReview, await StateAsync(session, set.Id)); + await page.GetByTestId("shortcut-help").ClickAsync(); + await Assertions.Expect(page.Locator("[data-ui=single-key-shortcuts]")).Not.ToBeCheckedAsync(); + + // After an enhanced navigation to another page, the switch still shows the stored choice. + await page.Keyboard.PressAsync("Escape"); + await page.Locator("a[aria-keyshortcuts=B]").ClickAsync(); + await page.WaitForURLAsync(new Regex("/\\?queue=awaiting-me$")); await page.GetByTestId("shortcut-help").ClickAsync(); await Assertions.Expect(page.Locator("[data-ui=single-key-shortcuts]")).Not.ToBeCheckedAsync(); } @@ -167,8 +178,14 @@ public async Task Arrow_keys_home_and_end_move_through_desk_rows_and_set_items() Assert.Equal(1, await page.EvaluateAsync(items)); await page.Keyboard.PressAsync("End"); Assert.Equal(4, await page.EvaluateAsync(items)); + await page.Keyboard.PressAsync("ArrowUp"); + Assert.Equal(3, await page.EvaluateAsync(items)); await page.Keyboard.PressAsync("Home"); Assert.Equal(0, await page.EvaluateAsync(items)); + + // J and K match their physical keys too, as on a Persian layout where the J key types "ت". + await page.EvaluateAsync("document.activeElement.dispatchEvent(new KeyboardEvent('keydown', { key: 'ت', code: 'KeyJ', bubbles: true }))"); + Assert.Equal(1, await page.EvaluateAsync(items)); } // Outside any dialog, a key typed into a field types: B in a generated form's title does not go back.