diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.spec.tsx index ccbe012ff..833aee90b 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.spec.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.spec.tsx @@ -99,7 +99,7 @@ describe("AssignmentEdit", () => { it("renders the assignment name and the section papers for a Regular assignment", () => { renderWithMantine(); - 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(); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx index 0e03e9e69..227cb9538 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/edit/AssignmentEdit.tsx @@ -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"; @@ -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(); @@ -433,7 +437,9 @@ export const AssignmentEdit = ({ > -

{selectedAssignment?.name}

+

+ {selectedAssignment?.name} +

{ it("renders the page title with the assignment count", () => { renderWithMantine(); - expect(screen.getByRole("heading", { name: "Assignments" })).toBeInTheDocument(); + expect(screen.getByRole("heading", { level: 1, name: "Assignments" })).toHaveFocus(); expect(screen.getByText("3")).toBeInTheDocument(); }); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx index 09d701732..6c6fbc4c9 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/list/AssignmentList.tsx @@ -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"; @@ -94,6 +95,7 @@ export const AssignmentList = ({ onBulkEnforceDueChange, onBulkRemove }: AssignmentListProps) => { + const headingRef = useRouteHeadingFocus(window.location.pathname); const [rowSelection, setRowSelection] = useState({}); const [sortField, setSortField] = useState( @@ -390,7 +392,9 @@ export const AssignmentList = ({
-

Assignments

+

+ Assignments +

{!loading && {assignments.length}}
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.spec.tsx index 93387af87..0c5e418ca 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.spec.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.spec.tsx @@ -130,9 +130,7 @@ describe("AssignmentWizard", () => { it("renders the active step title as the page h1", () => { renderWithMantine(); - 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", () => { diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.tsx index 27b541c8d..5bbc04432 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/AssignmentBuilder/components/wizard/AssignmentWizard.tsx @@ -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"; @@ -96,9 +97,13 @@ export const AssignmentWizard = ({ watch, setValue }: AssignmentWizardProps) => { + const headingRef = useRouteHeadingFocus(window.location.pathname); + const renderBasicInfo = () => ( <> -

Basic information

+

+ Basic information +

@@ -172,7 +177,9 @@ export const AssignmentWizard = ({ const renderTypeSelection = () => ( <> -

Select assignment type

+

+ Select assignment type +

{assignmentTypeCards.map((card) => { const isSelected = watch("kind") === card.type; @@ -318,7 +325,9 @@ export const AssignmentWizard = ({ const renderVisibility = () => ( <> -

Visibility

+

+ Visibility +

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. diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GraderPageSection.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GraderPageSection.tsx index 8da9e4749..212c93a12 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GraderPageSection.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GraderPageSection.tsx @@ -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"; @@ -12,12 +14,8 @@ export const GraderPageSection: React.FC = ({ heading, pathname }) => { - const headingRef = useRef(null); const pagePathname = pathname.replace(/\/students\/[^/]+\/?$/, ""); - - useEffect(() => { - headingRef.current?.focus(); - }, [pagePathname]); + const headingRef = useRouteHeadingFocus(pagePathname); return (

diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/hooks/useRouteHeadingFocus.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/hooks/useRouteHeadingFocus.spec.tsx new file mode 100644 index 000000000..f3a67e30c --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/hooks/useRouteHeadingFocus.spec.tsx @@ -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 ( + <> +

+ {location.pathname} +

+ Next page + + + ); +}; + +describe("useRouteHeadingFocus", () => { + it("focuses the heading on initial render and after navigation", async () => { + const user = userEvent.setup(); + + render( + + + + ); + + 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( + + + + ); + + const button = screen.getByRole("button", { name: "Unrelated action" }); + + await user.click(button); + + expect(button).toHaveFocus(); + }); +}); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/hooks/useRouteHeadingFocus.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/hooks/useRouteHeadingFocus.ts new file mode 100644 index 000000000..93d231519 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/hooks/useRouteHeadingFocus.ts @@ -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 => { + const headingRef = useRef(null); + + useEffect(() => { + headingRef.current?.focus(); + }, [routeKey]); + + return headingRef; +};