diff --git a/bases/rsptx/assignment_server_api/assignment_builder/.eslintrc.a11y.cjs b/bases/rsptx/assignment_server_api/assignment_builder/.eslintrc.a11y.cjs new file mode 100644 index 000000000..52ba370eb --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/.eslintrc.a11y.cjs @@ -0,0 +1,13 @@ +const jsxA11y = require("eslint-plugin-jsx-a11y"); + +const warningRules = Object.fromEntries( + Object.entries(jsxA11y.configs.recommended.rules).map(([ruleName, configuration]) => [ + ruleName, + Array.isArray(configuration) ? ["warn", ...configuration.slice(1)] : "warn" + ]) +); + +module.exports = { + extends: ["./.eslintrc.cjs", "plugin:jsx-a11y/recommended"], + rules: warningRules +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/README.md b/bases/rsptx/assignment_server_api/assignment_builder/README.md index 402d3d0ed..52a007e63 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/README.md +++ b/bases/rsptx/assignment_server_api/assignment_builder/README.md @@ -29,6 +29,15 @@ It correctly bundles React in production mode and optimizes the build for the be The build is minified and the filenames include the hashes.\ Your app is ready to be deployed! +### Accessibility checks + +Run `npm run lint:a11y` to report static JSX accessibility warnings. The command is +currently non-blocking so existing warnings can be addressed incrementally. + +Run `npm run test:e2e:a11y` to scan the assignment builder and grader with axe. Axe +violations are attached to the Playwright report and annotated, but do not fail the +test yet. Navigation and page-loading assertions still fail normally. + ### Production For production, the React app is currently mounted as a static app within the `assignment` server. This will need to change eventually so that we can server the correct versions of the rest of the runestone javascript. diff --git a/bases/rsptx/assignment_server_api/assignment_builder/e2e/consolidation/a11y-scan.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/e2e/consolidation/a11y-scan.spec.ts index c14b1aa59..5f096a583 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/e2e/consolidation/a11y-scan.spec.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/e2e/consolidation/a11y-scan.spec.ts @@ -5,37 +5,62 @@ import { expect, test } from "../fixtures/scratch"; const scan = (page: import("@playwright/test").Page) => new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa"]).analyze(); -const seriousViolations = (results: Awaited>) => - results.violations - .filter((v) => v.impact === "serious" || v.impact === "critical") - .map((v) => ({ - id: v.id, - impact: v.impact, - nodes: v.nodes.slice(0, 3).map((n) => n.target.join(" ")) - })); +const reportViolations = async ( + testInfo: import("@playwright/test").TestInfo, + results: Awaited> +) => { + const violations = results.violations.map((violation) => ({ + id: violation.id, + impact: violation.impact, + description: violation.description, + help: violation.help, + helpUrl: violation.helpUrl, + nodes: violation.nodes.map((node) => ({ + target: node.target, + html: node.html, + failureSummary: node.failureSummary + })) + })); + + await testInfo.attach("axe-accessibility-results", { + body: JSON.stringify(violations, null, 2), + contentType: "application/json" + }); + + if (violations.length > 0) { + testInfo.annotations.push({ + type: "accessibility", + description: `${violations.length} axe violation(s) reported; enforcement is not enabled yet` + }); + console.warn(`axe reported ${violations.length} accessibility violation(s)`); + } +}; test( - "assignment list has no serious or critical axe violations", + "reports assignment list axe violations", { tag: ["@p2", "@a11y", "@assignments"] }, - async ({ page }) => { + async ({ page }, testInfo) => { await page.goto("/builder"); await expect(page.getByRole("heading", { level: 1, name: "Assignments" })).toBeVisible(); const results = await scan(page); - expect(seriousViolations(results)).toEqual([]); + await reportViolations(testInfo, results); } ); test( - "grader assignment list has no serious or critical axe violations", + "reports grader assignment list axe violations", { tag: ["@p2", "@a11y", "@grader"] }, - async ({ page }) => { + async ({ page }, testInfo) => { await page.goto("/grader"); + await expect(page.getByRole("heading", { level: 1, name: "Assignment grader" })).toBeVisible(); + await expect(page.getByRole("heading", { level: 2, name: "Assignments" })).toBeAttached(); + await expect(page.getByRole("navigation", { name: "Breadcrumb" })).toContainText("Assignments"); await expect(page.getByRole("region", { name: "Assignments" })).toBeVisible(); const results = await scan(page); - expect(seriousViolations(results)).toEqual([]); + await reportViolations(testInfo, results); } ); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json b/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json index d875f24f0..dd77b4ec2 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json +++ b/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json @@ -97,6 +97,7 @@ "eslint-config-react-app": "^7.0.1", "eslint-plugin-flowtype": "^8.0.3", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-only-warn": "^1.1.0", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-react": "^7.37.2", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/package.json b/bases/rsptx/assignment_server_api/assignment_builder/package.json index ab624361e..b147bba83 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/package.json +++ b/bases/rsptx/assignment_server_api/assignment_builder/package.json @@ -84,7 +84,9 @@ "docs": "jsdoc -c jsdoc.config.json -r src", "test": "vitest --coverage.enabled", "test:eslint": "eslint src --max-warnings=0", + "lint:a11y": "eslint src --config .eslintrc.a11y.cjs", "test:e2e": "playwright test", + "test:e2e:a11y": "playwright test consolidation/a11y-scan.spec.ts --grep @a11y", "test:e2e:smoke": "playwright test --grep @p0", "test:e2e:p1": "playwright test --grep \"@p0|@p1\"", "test:e2e:docker": "E2E_TARGET=docker playwright test e2e/grader", @@ -125,6 +127,7 @@ "eslint-config-react-app": "^7.0.1", "eslint-plugin-flowtype": "^8.0.3", "eslint-plugin-import": "^2.31.0", + "eslint-plugin-jsx-a11y": "^6.10.2", "eslint-plugin-only-warn": "^1.1.0", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-react": "^7.37.2", 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 41828e4cb..cc0ba494a 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 @@ -33,6 +33,7 @@ gap: var(--rs-space-3); font-size: var(--rs-font-size-xl); font-weight: var(--rs-font-weight-heading); + margin: 0; } .titleText { color: var(--rs-text-strong); @@ -66,13 +67,23 @@ } .breadcrumb { - display: flex; - align-items: center; - gap: var(--rs-space-2); color: var(--rs-text-muted); font-size: var(--rs-font-size-md); margin-bottom: var(--rs-space-4); +} +.breadcrumbList { + display: flex; + align-items: center; + gap: var(--rs-space-2); flex-wrap: wrap; + list-style: none; + margin: 0; + padding: 0; +} +.breadcrumbItem { + display: inline-flex; + align-items: center; + gap: var(--rs-space-2); } .breadcrumb a { color: var(--rs-brand-600); @@ -102,7 +113,19 @@ .breadcrumbPath { margin-left: auto; font-size: var(--rs-font-size-xs); - color: var(--rs-text-subtle); + color: var(--rs-text-muted); +} + +.visuallyHidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } .grid { @@ -235,7 +258,7 @@ margin-top: var(--rs-space-3); } .metaIcon { - color: var(--rs-brand-500); + color: var(--rs-brand-600); } .iconText { @@ -279,11 +302,11 @@ } .emptyStateIcon { font-size: 30px; - color: var(--rs-brand-400); + color: var(--rs-brand-600); } .emptyStateIconWarn { font-size: 30px; - color: var(--rs-warning); + color: var(--rs-warning-text); } .loadingWrap { @@ -359,7 +382,7 @@ color: var(--rs-text); } .cellSubtle { - color: var(--rs-text-subtle); + color: var(--rs-text-muted); font-weight: var(--rs-font-weight-medium); } .cellMuted { @@ -373,10 +396,10 @@ vertical-align: middle; } .cellDash { - color: var(--rs-border-strong); + color: var(--rs-text-muted); } .cellLink { - color: var(--rs-brand-500); + color: var(--rs-brand-600); } .studentNameCell { display: flex; @@ -441,7 +464,7 @@ box-shadow: 0 0 0 2px var(--rs-border-brand); } .dateFilterInput::placeholder { - color: var(--rs-text-subtle); + color: var(--rs-text-muted); } .dateFilterWrapper :global(.react-datepicker__close-icon) { padding: 0 6px 0 0; @@ -530,7 +553,7 @@ min-width: 0; } .gradePaneAvatar { - color: var(--rs-brand-500); + color: var(--rs-brand-600); } .gradePaneIdentity { flex: 1; @@ -651,7 +674,7 @@ padding: 0.25rem; } .toastIcon { - color: var(--rs-success); + color: var(--rs-success-text); } .toastText { flex: 1; @@ -771,7 +794,7 @@ } .attemptTimestamp { font-size: 11px; - color: var(--rs-text-subtle); + color: var(--rs-text-muted); font-variant-numeric: tabular-nums; } .latestBadge { @@ -907,7 +930,7 @@ .selectionChip button { border: none; background: transparent; - color: var(--rs-brand-500); + color: var(--rs-brand-600); font-weight: var(--rs-font-weight-semibold); cursor: pointer; font-size: var(--rs-font-size-sm); @@ -952,7 +975,7 @@ color: var(--rs-text-muted); } .textSubtle { - color: var(--rs-text-subtle); + color: var(--rs-text-muted); } .textStrong { color: var(--rs-text-strong); @@ -1023,7 +1046,7 @@ color: var(--rs-text-strong); } .dialogHeaderIcon { - color: var(--rs-brand-500); + color: var(--rs-brand-600); } .sidChip { font-family: var(--rs-font-mono); @@ -1050,7 +1073,7 @@ } .kbdSep { margin: 0 6px; - color: var(--rs-text-subtle); + color: var(--rs-text-muted); } .shortcutTable { width: 100%; @@ -1068,7 +1091,7 @@ } .dialogFootnote { font-size: 11px; - color: var(--rs-text-subtle); + color: var(--rs-text-muted); border-top: 1px solid var(--rs-border-solid); padding-top: var(--rs-space-2); } @@ -1079,7 +1102,7 @@ padding: 0; font-family: inherit; font-size: 13px; - color: var(--rs-brand-500); + color: var(--rs-brand-600); cursor: pointer; font-weight: var(--rs-font-weight-medium); } @@ -1309,7 +1332,7 @@ background: var(--rs-neutral-100); } .gradebookCellButton:focus-visible { - outline: 2px solid var(--rs-brand); + outline: 2px solid var(--rs-brand-600); outline-offset: 1px; } .gradebookCellManual { diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.tsx index d6900c1a9..f12996c01 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.tsx @@ -5,11 +5,13 @@ import { useGetGraderQuestionsQuery } from "@store/grader/grader.logic.api"; import React, { useState } from "react"; -import { Link, Outlet, useLocation, useParams } from "react-router-dom"; +import { Outlet, useLocation, useParams } from "react-router-dom"; import { Icon } from "@/components/ui/Icon"; import styles from "./Grader.module.css"; +import { GraderBreadcrumb } from "./components/GraderBreadcrumb"; +import { GraderPageSection } from "./components/GraderPageSection"; import { GraderSectionNav } from "./components/GraderSectionNav"; import { ShortcutsHelpDialog } from "./components/ShortcutsHelpDialog"; import { useEnsureEbookConfigForGrader } from "./hooks/useEnsureEbookConfigForGrader"; @@ -53,6 +55,12 @@ const GraderShell: React.FC = () => { isDemo && assignmentId && questionId ? getDemoAnswersFor(assignmentId, questionId) : aRealData; const questionMeta = qData?.questions.find((q) => q.id === questionId); const progress = aData ? getQuestionProgress(aData.answers, questionMeta) : null; + const isGradebook = location.pathname.endsWith("/gradebook"); + const pageHeading = isGradebook + ? "Gradebook" + : questionId + ? questionMeta?.name || `Question #${questionId}` + : assignment?.name || "Assignments"; const [helpOpen, setHelpOpen] = useState(false); @@ -62,10 +70,10 @@ const GraderShell: React.FC = () => { return (
-
-
+
+

Assignment grader -

+
-
+ - + -
+ -
+ > = {}) => + render( + + + + ); + +describe("GraderBreadcrumb", () => { + it("marks Assignments as the current page on the grader landing page", () => { + renderBreadcrumb(); + + const breadcrumb = screen.getByRole("navigation", { name: "Breadcrumb" }); + + expect(within(breadcrumb).getByRole("list")).toBeInTheDocument(); + expect(within(breadcrumb).getByText("Assignments")).toHaveAttribute("aria-current", "page"); + }); + + it("links back to Assignments and marks Gradebook as current", () => { + renderBreadcrumb({ isGradebook: true }); + + expect(screen.getByRole("link", { name: "Assignments" })).toHaveAttribute("href", "/grader"); + expect(screen.getByText("Gradebook")).toHaveAttribute("aria-current", "page"); + }); + + it("exposes the assignment and question hierarchy with progress", () => { + renderBreadcrumb({ + assignment: { id: 12, name: "Quiz 1" }, + questionId: 34, + progress: { autograded: 2, graded: 3, total: 8 } + }); + + expect(screen.getByRole("link", { name: "Assignments" })).toHaveAttribute("href", "/grader"); + expect(screen.getByRole("link", { name: "Quiz 1" })).toHaveAttribute("href", "/grader/12"); + expect(screen.getByText(/Question #34/)).toHaveAttribute("aria-current", "page"); + expect(screen.getByLabelText("5 of 8 students graded")).toBeInTheDocument(); + }); +}); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GraderBreadcrumb.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GraderBreadcrumb.tsx new file mode 100644 index 000000000..19095a8c5 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GraderBreadcrumb.tsx @@ -0,0 +1,94 @@ +import React from "react"; +import { Link } from "react-router-dom"; + +import { Icon } from "@/components/ui/Icon"; + +import styles from "../Grader.module.css"; + +interface BreadcrumbAssignment { + id: number; + name: string; +} + +interface BreadcrumbProgress { + autograded: number; + graded: number; + total: number; +} + +interface GraderBreadcrumbProps { + assignment?: BreadcrumbAssignment; + isGradebook: boolean; + progress?: BreadcrumbProgress | null; + questionId?: number; +} + +const Separator: React.FC = () => ( + +); + +export const GraderBreadcrumb: React.FC = ({ + assignment, + isGradebook, + progress, + questionId +}) => { + const assignmentsCurrent = !isGradebook && !assignment; + + return ( + + ); +}; + +export default GraderBreadcrumb; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GraderPageSection.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GraderPageSection.spec.tsx new file mode 100644 index 000000000..378b38686 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GraderPageSection.spec.tsx @@ -0,0 +1,35 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { GraderPageSection } from "./GraderPageSection"; + +describe("GraderPageSection", () => { + it("labels the page region with a level-two heading", () => { + render( + +

Page content

+
+ ); + + const heading = screen.getByRole("heading", { level: 2, name: "Assignments" }); + + expect(screen.getByRole("region", { name: "Assignments" })).toBeInTheDocument(); + expect(heading).toHaveFocus(); + }); + + it("moves focus to the new heading after client-side navigation", () => { + const { rerender } = render( + +

Page content

+
+ ); + + rerender( + +

Page content

+
+ ); + + expect(screen.getByRole("heading", { level: 2, name: "Gradebook" })).toHaveFocus(); + }); +}); 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 new file mode 100644 index 000000000..47e3188ac --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/GraderPageSection.tsx @@ -0,0 +1,31 @@ +import React, { PropsWithChildren, useEffect, useRef } from "react"; + +import styles from "../Grader.module.css"; + +interface GraderPageSectionProps extends PropsWithChildren { + heading: string; + pathname: string; +} + +export const GraderPageSection: React.FC = ({ + children, + heading, + pathname +}) => { + const headingRef = useRef(null); + + useEffect(() => { + headingRef.current?.focus(); + }, [pathname]); + + return ( +
+

+ {heading} +

+ {children} +
+ ); +}; + +export default GraderPageSection;