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
11 changes: 10 additions & 1 deletion src/FluentCMS.Admin/Components/Pages/ChangeSetView.razor
Original file line number Diff line number Diff line change
Expand Up @@ -397,7 +397,9 @@
private IReadOnlyList<KeyboardShortcut> 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"]),
Expand Down Expand Up @@ -574,6 +576,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."];
Expand All @@ -596,6 +600,11 @@
}

await LoadAsync();
if (result.IsSuccess)
{
_next = next;
}

return result.IsSuccess ? result.Value : null;
}

Expand Down
1 change: 1 addition & 0 deletions src/FluentCMS.Admin/Components/Pages/Desk.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
];
Expand Down
4 changes: 4 additions & 0 deletions src/FluentCMS.Admin/Components/Review/ShortcutHelp.razor
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
<DialogTitle>@L["Keyboard shortcuts"]</DialogTitle>
<DialogDescription>@L["Keys work anywhere on the page except while you type in a field."]</DialogDescription>
</DialogHeader>
@* 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. *@
<FormField Label="@L["Single-key shortcuts"]" Description="@L["Keys without Control, Alt or Meta, such as A, J or 1. Turn them off if they act when you do not mean them to, as with voice control."]" Class="rounded-md border p-3">
<Switch checked data-ui="single-key-shortcuts" />
</FormField>
<dl class="grid grid-cols-[max-content_1fr] items-center gap-x-4 gap-y-2 text-sm" data-testid="shortcut-list">
@foreach (var shortcut in Shortcuts)
{
Expand Down
60 changes: 56 additions & 4 deletions src/FluentCMS.Admin/wwwroot/js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -343,6 +345,39 @@ 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";

// 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 {
return true;
}
}

function chooseSingleKeys(on) {
try {
if (on) localStorage.removeItem(singleKeysStorage);
else localStorage.setItem(singleKeysStorage, "off");
singleKeysChosen = undefined;
} catch {
singleKeysChosen = on;
}
}

// 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]");
Expand All @@ -364,7 +399,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;
Expand All @@ -390,11 +425,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;
Expand Down Expand Up @@ -592,6 +628,21 @@ 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);
});

// 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);
});
Expand All @@ -605,4 +656,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 });
126 changes: 126 additions & 0 deletions tests/FluentCMS.IntegrationTests/Browser/KeyboardReviewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,132 @@ 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<int>("[...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-testid=change-set-items][data-interactive=true]")).ToBeVisibleAsync();
await page.Keyboard.PressAsync("j");
await page.Keyboard.PressAsync("?");
Assert.Equal(-1, await page.EvaluateAsync<int>("[...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();
}

[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<int>(rows));
await page.Keyboard.PressAsync("ArrowDown");
Assert.Equal(1, await page.EvaluateAsync<int>(rows));
await page.Keyboard.PressAsync("End");
Assert.Equal(2, await page.EvaluateAsync<int>(rows));
await page.Keyboard.PressAsync("ArrowUp");
Assert.Equal(1, await page.EvaluateAsync<int>(rows));
await page.Keyboard.PressAsync("Home");
Assert.Equal(0, await page.EvaluateAsync<int>(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<int>(items));
await page.Keyboard.PressAsync("ArrowDown");
Assert.Equal(1, await page.EvaluateAsync<int>(items));
await page.Keyboard.PressAsync("End");
Assert.Equal(4, await page.EvaluateAsync<int>(items));
await page.Keyboard.PressAsync("ArrowUp");
Assert.Equal(3, await page.EvaluateAsync<int>(items));
await page.Keyboard.PressAsync("Home");
Assert.Equal(0, await page.EvaluateAsync<int>(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<int>(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<IContentTypeDirectory, ContentType>(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<string?> FocusedTextAsync(IPage page)
{
return (await page.EvaluateAsync<string?>("document.activeElement?.textContent"))?.Trim();
Expand Down
3 changes: 2 additions & 1 deletion tests/FluentCMS.Tests/Admin/ChangeSetViewTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,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]
Expand Down
5 changes: 4 additions & 1 deletion tests/FluentCMS.Tests/Admin/DeskTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading