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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,8 @@ export const ShortcutsHelpDialog: React.FC<Props> = ({ visible, onHide }) => {
</div>
))}
<div className={styles.dialogFootnote}>
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.
<br />
Tip: enable <strong>Auto-advance after save</strong> in the grade panel to jump to the
next ungraded student automatically. You can always undo from the toast that appears.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,42 @@ export const StudentListSidebar: React.FC<Props> = ({
}, [answers, filter, hideFullCredit, hideUnanswered]);

const listRef = useRef<HTMLUListElement | null>(null);
const focusableSid = filtered.some((answer) => answer.sid === activeSid)
? activeSid
: filtered[0]?.sid;

const handleOptionKeyDown = (event: React.KeyboardEvent<HTMLLIElement>, 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<HTMLElement>("[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 (
<aside className={styles.sidebar} data-tour="grader-student-sidebar" aria-label="Students">
Expand Down Expand Up @@ -158,17 +194,13 @@ export const StudentListSidebar: React.FC<Props> = ({
key={a.sid}
role="option"
aria-selected={active}
data-sid={a.sid}
data-tour={active ? "grader-active-student" : undefined}
title={lateTitle}
className={`${styles.item} ${active ? styles.active : ""} ${late ? styles.late : ""}`}
onClick={() => onSelect(a.sid)}
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect(a.sid);
}
}}
tabIndex={a.sid === focusableSid ? 0 : -1}
onKeyDown={(event) => handleOptionKeyDown(event, a.sid)}
>
<StatusDot status={status} />
<div className={styles.itemBody}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import userEvent from "@testing-library/user-event";

import { renderWithMantine, screen } from "@/test/renderWithMantine";

import { ViewModeToggle } from "./ViewModeToggle";

describe("ViewModeToggle", () => {
it("moves between view options with arrow keys", async () => {
const onChange = vi.fn();

renderWithMantine(<ViewModeToggle value="cards" onChange={onChange} />);

screen.getByRole("radio", { name: /cards/i }).focus();
await userEvent.keyboard("{ArrowRight}");

expect(onChange).toHaveBeenCalledWith("table");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ const makeInput = (tag: "INPUT" | "TEXTAREA" | "SELECT"): HTMLElement => {
return el;
};

const makeInteractive = (tag: "BUTTON" | "DIV", role?: string): HTMLElement => {
const el = document.createElement(tag);

if (role) el.setAttribute("role", role);
document.body.appendChild(el);
return el;
};

describe("useGraderHotkeys", () => {
beforeEach(() => {
mockDetectPlatform.mockReturnValue("other");
Expand Down Expand Up @@ -160,7 +168,7 @@ describe("useGraderHotkeys", () => {
});
});

describe("input target — keys are suppressed", () => {
describe("interactive target — keys are suppressed", () => {
it("does not call next when target is INPUT", () => {
const handlers: GraderHotkeyHandlers = { next: vi.fn() };

Expand Down Expand Up @@ -200,6 +208,30 @@ describe("useGraderHotkeys", () => {

expect(handlers.openHelp).not.toHaveBeenCalled();
});

it("does not intercept ArrowDown from a menu button", () => {
const handlers: GraderHotkeyHandlers = { next: vi.fn() };
const button = makeInteractive("BUTTON");

renderHook(() => useGraderHotkeys(handlers));

fireKeyDown("ArrowDown", {}, button);

expect(handlers.next).not.toHaveBeenCalled();
button.remove();
});

it("does not intercept arrows from a listbox option", () => {
const handlers: GraderHotkeyHandlers = { prev: vi.fn() };
const option = makeInteractive("DIV", "option");

renderHook(() => useGraderHotkeys(handlers));

fireKeyDown("ArrowUp", {}, option);

expect(handlers.prev).not.toHaveBeenCalled();
option.remove();
});
});

describe("modifier key — keys are suppressed (non-mac uses ctrlKey)", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,31 @@ interface Options {
enabled?: boolean;
}

const isInputTarget = (t: EventTarget | null): boolean => {
if (!(t instanceof HTMLElement)) return false;
const tag = t.tagName;

if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
return t.isContentEditable;
};
const INTERACTIVE_SELECTOR = [
"a[href]",
"button",
"input",
"select",
"summary",
"textarea",
"[contenteditable='true']",
"[role='button']",
"[role='checkbox']",
"[role='combobox']",
"[role='listbox']",
"[role='menu']",
"[role='menuitem']",
"[role='option']",
"[role='radio']",
"[role='slider']",
"[role='spinbutton']",
"[role='switch']",
"[role='tab']",
"[role='textbox']"
].join(",");

const isInteractiveTarget = (target: EventTarget | null): boolean =>
target instanceof Element && target.closest(INTERACTIVE_SELECTOR) !== null;

export const useGraderHotkeys = (
handlers: GraderHotkeyHandlers,
Expand All @@ -43,14 +61,14 @@ export const useGraderHotkeys = (

const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "?" && !isMod(e)) {
if (!isInputTarget(e.target)) {
if (!isInteractiveTarget(e.target)) {
e.preventDefault();
handlers.openHelp?.();
}
return;
}

if (isInputTarget(e.target) || isMod(e) || e.altKey) return;
if (isInteractiveTarget(e.target) || isMod(e) || e.altKey) return;

switch (e.key) {
case "j":
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -200,15 +200,12 @@ export const GraderQuestionsPage: React.FC = () => {
/>
),
cell: ({ row }) => (
<span
data-tour="grader-question-select"
onClick={(e) => e.stopPropagation()}
style={{ display: "inline-flex" }}
>
<span data-tour="grader-question-select" style={{ display: "inline-flex" }}>
<Checkbox
aria-label={`Select ${row.original.name}`}
checked={selectedQuestionIds.includes(row.original.id)}
onChange={() => toggleSelected(row.original.id)}
onClick={(event) => event.stopPropagation()}
/>
</span>
)
Expand Down Expand Up @@ -505,21 +502,19 @@ export const GraderQuestionsPage: React.FC = () => {
key={q.id}
className={`${styles.cardSelectable} ${selected ? styles.cardSelected : ""}`}
>
<span
className={styles.cardCheckbox}
data-tour="grader-question-select"
onClick={(e) => e.stopPropagation()}
>
<span className={styles.cardCheckbox} data-tour="grader-question-select">
<Checkbox
aria-label={`Select ${q.name}`}
checked={selected}
onChange={() => toggleSelected(q.id)}
onClick={(event) => event.stopPropagation()}
/>
</span>
<button
data-tour="grader-question-card"
className={styles.card}
onClick={() => navigate(`/grader/${id}/questions/${q.id}`)}
aria-label={`Grade ${q.name}`}
>
<div className={`${styles.cardTitle} ${styles.cardTitleSpread}`}>
<span className={styles.cardTitleText} title={q.name}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,36 @@ describe("AppNavBar", () => {
await waitFor(() => expect(logout).toHaveBeenCalledTimes(1));
});

it("supports arrow navigation and closes a submenu with Escape", async () => {
const items: NavItem[] = [
{
label: "Help",
items: [
{ label: "Guide", command: vi.fn() },
{ label: "Contact", command: vi.fn() }
]
}
];

renderWithMantine(<AppNavBar items={items} />);
const helpButton = screen.getByRole("button", { name: /help/i });

helpButton.focus();
await userEvent.keyboard("{Enter}");

const menu = await screen.findByRole("menu");
const menuItems = within(menu).getAllByRole("menuitem");

await userEvent.keyboard("{ArrowDown}");
expect(menuItems[0]).toHaveFocus();
await userEvent.keyboard("{ArrowDown}");
expect(menuItems[1]).toHaveFocus();

await userEvent.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("menu")).not.toBeInTheDocument());
expect(helpButton).toHaveFocus();
});

it("hides sub-items whose visible flag is false", async () => {
renderWithMantine(<AppNavBar items={makeItems()} />);
await userEvent.click(screen.getByRole("button", { name: /user/i }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,39 @@
color: var(--rs-text-muted);
}

.clickableRow:focus-visible {
outline: none;
}

.clickableRow:focus-visible > td {
background: var(--rs-brand-50);
box-shadow:
inset 0 3px 0 var(--rs-brand-500),
inset 0 -3px 0 var(--rs-brand-500);
}

.clickableRow:focus-visible > td:first-child {
box-shadow:
inset 3px 0 0 var(--rs-brand-500),
inset 0 3px 0 var(--rs-brand-500),
inset 0 -3px 0 var(--rs-brand-500);
}

.clickableRow:focus-visible > td:last-child {
box-shadow:
inset -3px 0 0 var(--rs-brand-500),
inset 0 3px 0 var(--rs-brand-500),
inset 0 -3px 0 var(--rs-brand-500);
}

.clickableRow:focus-visible > td:only-child {
box-shadow:
inset 3px 0 0 var(--rs-brand-500),
inset -3px 0 0 var(--rs-brand-500),
inset 0 3px 0 var(--rs-brand-500),
inset 0 -3px 0 var(--rs-brand-500);
}

.footer {
padding: var(--rs-space-3) var(--rs-space-4);
}
Expand Down
Loading
Loading