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 @@ -99,7 +99,7 @@ describe("AssignmentEdit", () => {
it("renders the assignment name and the section papers for a Regular assignment", () => {
renderWithMantine(<Harness kind="Regular" />);

expect(screen.getByRole("heading", { name: "Homework 3" })).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1, name: "Homework 3" })).toHaveFocus();
expect(screen.getByRole("heading", { name: "Details" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Schedule" })).toBeInTheDocument();
expect(screen.getByRole("heading", { name: "Scoring" })).toBeInTheDocument();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import classNames from "classnames";
import { Control, Controller, UseFormSetValue } from "react-hook-form";

import { useExercisesSelector } from "@/hooks/useExercisesSelector";
import { useRouteHeadingFocus } from "@/hooks/useRouteHeadingFocus";
import { Assignment, KindOfAssignment } from "@/types/assignment";

import { DateTimePicker } from "../../../../ui/DateTimePicker";
Expand Down Expand Up @@ -70,6 +71,9 @@ export const AssignmentEdit = ({
setValue
}: AssignmentEditProps) => {
const { isExercisesError, isExercisesLoading } = useExercisesSelector();
const headingRef = useRouteHeadingFocus(
`${window.location.pathname}:${isExercisesLoading ? "loading" : "ready"}`
);
// The study setting is meaningless outside a course set up to run one, so it should not confuse instructors
const { data: { asyncConditionsEnabled = false } = {} } = useHasApiKeyQuery();

Expand Down Expand Up @@ -433,7 +437,9 @@ export const AssignmentEdit = ({
>
<Icon name="arrow-left" />
</ActionIcon>
<h1 className={styles.assignmentName}>{selectedAssignment?.name}</h1>
<h1 ref={headingRef} className={styles.assignmentName} tabIndex={-1}>
{selectedAssignment?.name}
</h1>
</Group>
<Tooltip label="Preview as student" position="bottom">
<ActionIcon
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ describe("AssignmentList", () => {
it("renders the page title with the assignment count", () => {
renderWithMantine(<AssignmentList {...baseProps()} />);

expect(screen.getByRole("heading", { name: "Assignments" })).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1, name: "Assignments" })).toHaveFocus();
expect(screen.getByText("3")).toBeInTheDocument();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import classNames from "classnames";
import { useCallback, useEffect, useMemo, useState } from "react";

import { usePersistedPagination } from "@/hooks/usePersistedPagination";
import { useRouteHeadingFocus } from "@/hooks/useRouteHeadingFocus";
import { Assignment } from "@/types/assignment";
import { formatUTCDateForDisplay } from "@/utils/date";

Expand Down Expand Up @@ -94,6 +95,7 @@ export const AssignmentList = ({
onBulkEnforceDueChange,
onBulkRemove
}: AssignmentListProps) => {
const headingRef = useRouteHeadingFocus(window.location.pathname);
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});

const [sortField, setSortField] = useState<string>(
Expand Down Expand Up @@ -390,7 +392,9 @@ export const AssignmentList = ({
<div className={styles.page}>
<div className={styles.pageHeader}>
<div className={styles.titleGroup}>
<h1 className={styles.pageTitle}>Assignments</h1>
<h1 ref={headingRef} className={styles.pageTitle} tabIndex={-1}>
Assignments
</h1>
{!loading && <span className={styles.countBadge}>{assignments.length}</span>}
</div>
<div className={styles.headerActions}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,9 +130,7 @@ describe("AssignmentWizard", () => {
it("renders the active step title as the page h1", () => {
renderWithMantine(<Harness wizardStep="basic" />);

expect(
screen.getByRole("heading", { level: 1, name: "Basic information" })
).toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1, name: "Basic information" })).toHaveFocus();
});

it("disables Create assignment while the creation request is pending", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import classNames from "classnames";
import { Control, Controller, UseFormSetValue, UseFormWatch } from "react-hook-form";

import stepperStyles from "@/components/ui/WizardStepper.module.css";
import { useRouteHeadingFocus } from "@/hooks/useRouteHeadingFocus";
import { Assignment, KindOfAssignment } from "@/types/assignment";

import { DateTimePicker } from "../../../../ui/DateTimePicker";
Expand Down Expand Up @@ -96,9 +97,13 @@ export const AssignmentWizard = ({
watch,
setValue
}: AssignmentWizardProps) => {
const headingRef = useRouteHeadingFocus(window.location.pathname);

const renderBasicInfo = () => (
<>
<h1 className={styles.stepTitle}>Basic information</h1>
<h1 ref={headingRef} className={styles.stepTitle} tabIndex={-1}>
Basic information
</h1>
<div className={styles.fields}>
<div className={styles.field}>
<label htmlFor="wizard-assignment-name">Assignment name</label>
Expand Down Expand Up @@ -172,7 +177,9 @@ export const AssignmentWizard = ({

const renderTypeSelection = () => (
<>
<h1 className={styles.stepTitle}>Select assignment type</h1>
<h1 ref={headingRef} className={styles.stepTitle} tabIndex={-1}>
Select assignment type
</h1>
<div className={styles.typeCards}>
{assignmentTypeCards.map((card) => {
const isSelected = watch("kind") === card.type;
Expand Down Expand Up @@ -318,7 +325,9 @@ export const AssignmentWizard = ({

const renderVisibility = () => (
<>
<h1 className={styles.stepTitle}>Visibility</h1>
<h1 ref={headingRef} className={styles.stepTitle} tabIndex={-1}>
Visibility
</h1>
<p className={styles.visibilityIntro}>
Control when this assignment becomes visible to students. You can make it visible
immediately, schedule it for a future date, or set it to hide automatically.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import React, { PropsWithChildren, useEffect, useRef } from "react";
import React, { PropsWithChildren } from "react";

import { useRouteHeadingFocus } from "@/hooks/useRouteHeadingFocus";

import styles from "../Grader.module.css";

Expand All @@ -12,12 +14,8 @@ export const GraderPageSection: React.FC<GraderPageSectionProps> = ({
heading,
pathname
}) => {
const headingRef = useRef<HTMLHeadingElement>(null);
const pagePathname = pathname.replace(/\/students\/[^/]+\/?$/, "");

useEffect(() => {
headingRef.current?.focus();
}, [pagePathname]);
const headingRef = useRouteHeadingFocus(pagePathname);

return (
<section className={styles.body} aria-labelledby="grader-page-heading">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Link, MemoryRouter, useLocation } from "react-router-dom";
import { describe, expect, it } from "vitest";

import { useRouteHeadingFocus } from "./useRouteHeadingFocus";

const TestPage = () => {
const location = useLocation();
const headingRef = useRouteHeadingFocus(location.pathname);

return (
<>
<h1 ref={headingRef} tabIndex={-1}>
{location.pathname}
</h1>
<Link to="/next">Next page</Link>
<button type="button">Unrelated action</button>
</>
);
};

describe("useRouteHeadingFocus", () => {
it("focuses the heading on initial render and after navigation", async () => {
const user = userEvent.setup();

render(
<MemoryRouter initialEntries={["/start"]}>
<TestPage />
</MemoryRouter>
);

expect(screen.getByRole("heading", { level: 1, name: "/start" })).toHaveFocus();

await user.click(screen.getByRole("link", { name: "Next page" }));

expect(screen.getByRole("heading", { level: 1, name: "/next" })).toHaveFocus();
});

it("does not steal focus when the route is unchanged", async () => {
const user = userEvent.setup();

render(
<MemoryRouter initialEntries={["/start"]}>
<TestPage />
</MemoryRouter>
);

const button = screen.getByRole("button", { name: "Unrelated action" });

await user.click(button);

expect(button).toHaveFocus();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { RefObject, useEffect, useRef } from "react";

/**
* Moves focus to a page heading when its route changes so assistive
* technology announces the newly rendered page context.
*/
export const useRouteHeadingFocus = (routeKey: string): RefObject<HTMLHeadingElement> => {
const headingRef = useRef<HTMLHeadingElement>(null);

useEffect(() => {
headingRef.current?.focus();
}, [routeKey]);

return headingRef;
};
Loading