From f946b4f165dc05e55f6ed66a0f6722dcfe22a7af Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Tue, 13 Oct 2026 19:48:54 +0200 Subject: [PATCH] f-1579 improve keyboard accessibility in Grader --- .../routes/Grader/Grader.module.css | 4 ++ .../Grader/components/ShortcutsHelpDialog.tsx | 4 +- .../components/StudentListSidebar.module.css | 4 ++ .../components/StudentListSidebar.spec.tsx | 42 ++++++++++++ .../Grader/components/StudentListSidebar.tsx | 46 +++++++++++-- .../Grader/components/ViewModeToggle.spec.tsx | 18 +++++ .../Grader/hooks/useGraderHotkeys.spec.ts | 34 +++++++++- .../routes/Grader/hooks/useGraderHotkeys.ts | 36 +++++++--- .../GraderQuestionsPage.tsx | 15 ++--- .../src/components/shell/AppNavBar.spec.tsx | 30 +++++++++ .../src/components/ui/DataGrid.module.css | 33 +++++++++ .../src/components/ui/DataGrid.spec.tsx | 67 ++++++++++++++++++- .../src/components/ui/DataGrid.tsx | 48 +++++++++++++ 13 files changed, 351 insertions(+), 30 deletions(-) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ViewModeToggle.spec.tsx diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css index cc0ba494a..b886eb751 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css @@ -163,6 +163,10 @@ border-color: var(--rs-border-brand); box-shadow: var(--rs-shadow-sm); } +.card:focus-visible { + outline: 3px solid var(--rs-brand-500); + outline-offset: 2px; +} .card:hover::before { opacity: 1; } diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx index 89f1af467..87c19f7f6 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx @@ -87,8 +87,8 @@ export const ShortcutsHelpDialog: React.FC = ({ visible, onHide }) => { ))}
- Letter shortcuts are ignored while a text field has focus. Finish typing, then click - outside the field to use them. + Grader shortcuts are ignored while a form control, menu, link, or list option has focus. + Move focus back to the page to use them.
Tip: enable Auto-advance after save in the grade panel to jump to the next ungraded student automatically. You can always undo from the toast that appears. diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css index 7d4d57e89..1b58986ed 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css @@ -115,6 +115,10 @@ .item:hover { background: var(--rs-surface-sunken); } +.item:focus-visible { + outline: 3px solid var(--rs-brand-500); + outline-offset: 2px; +} .item.active { background: var(--rs-brand-50); box-shadow: inset 3px 0 0 var(--rs-brand-500); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.spec.tsx index 61821df84..45d1234b7 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.spec.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.spec.tsx @@ -90,6 +90,48 @@ describe("StudentListSidebar", () => { expect(onSelect).toHaveBeenCalledWith("s2"); }); + it("keeps only the active option in the Tab order", () => { + renderSidebar({ activeSid: "s2" }); + const options = screen.getAllByRole("option"); + + expect(options[0]).toHaveAttribute("tabindex", "-1"); + expect(options[1]).toHaveAttribute("tabindex", "0"); + expect(options[2]).toHaveAttribute("tabindex", "-1"); + }); + + it("uses the first visible option as the Tab stop when the active student is filtered out", () => { + renderSidebar({ activeSid: "missing" }); + const options = screen.getAllByRole("option"); + + expect(options[0]).toHaveAttribute("tabindex", "0"); + expect(options[1]).toHaveAttribute("tabindex", "-1"); + }); + + it("moves and selects with ArrowDown", async () => { + const { onSelect } = renderSidebar({ activeSid: "s1" }); + const options = screen.getAllByRole("option"); + + options[0].focus(); + await userEvent.keyboard("{ArrowDown}"); + + expect(options[1]).toHaveFocus(); + expect(onSelect).toHaveBeenCalledWith("s2"); + }); + + it("supports Home and End navigation", async () => { + const { onSelect } = renderSidebar({ activeSid: "s2" }); + const options = screen.getAllByRole("option"); + + options[1].focus(); + await userEvent.keyboard("{End}"); + expect(options[2]).toHaveFocus(); + expect(onSelect).toHaveBeenLastCalledWith("s3"); + + await userEvent.keyboard("{Home}"); + expect(options[0]).toHaveFocus(); + expect(onSelect).toHaveBeenLastCalledWith("s1"); + }); + it("reports changes to both student filters", async () => { const { onToggleHideFullCredit, onToggleHideUnanswered } = renderSidebar(); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx index 5168f315f..7c7e4d142 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx @@ -68,6 +68,42 @@ export const StudentListSidebar: React.FC = ({ }, [answers, filter, hideFullCredit, hideUnanswered]); const listRef = useRef(null); + const focusableSid = filtered.some((answer) => answer.sid === activeSid) + ? activeSid + : filtered[0]?.sid; + + const handleOptionKeyDown = (event: React.KeyboardEvent, sid: string) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onSelect(sid); + return; + } + + if (!["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; + + const options = Array.from( + listRef.current?.querySelectorAll("[role='option']") ?? [] + ); + const currentIndex = options.indexOf(event.currentTarget); + + if (currentIndex < 0 || options.length === 0) return; + + event.preventDefault(); + + const nextIndex = + event.key === "Home" + ? 0 + : event.key === "End" + ? options.length - 1 + : event.key === "ArrowDown" + ? Math.min(currentIndex + 1, options.length - 1) + : Math.max(currentIndex - 1, 0); + const nextOption = options[nextIndex]; + const nextSid = nextOption.dataset.sid; + + nextOption.focus(); + if (nextSid && nextSid !== sid) onSelect(nextSid); + }; return (