diff --git a/packages/perseus-editor/package.json b/packages/perseus-editor/package.json index b6a6b66d549..8df899537cc 100644 --- a/packages/perseus-editor/package.json +++ b/packages/perseus-editor/package.json @@ -43,6 +43,7 @@ "@khanacademy/perseus-linter": "workspace:*", "@khanacademy/perseus-score": "workspace:*", "@khanacademy/perseus-utils": "workspace:*", + "axe-core": "^4.11.0", "katex": "0.11.1", "mafs": "^0.19.0", "tiny-invariant": "catalog:prodDeps" diff --git a/packages/perseus-editor/src/__docs__/preview-panel.tsx b/packages/perseus-editor/src/__docs__/preview-panel.tsx index 0b7af92e9dd..d586b09a37d 100644 --- a/packages/perseus-editor/src/__docs__/preview-panel.tsx +++ b/packages/perseus-editor/src/__docs__/preview-panel.tsx @@ -78,7 +78,9 @@ function PreviewPanel({ /> - {children} + + {children} + )} diff --git a/packages/perseus-editor/src/__tests__/issues-panel.test.tsx b/packages/perseus-editor/src/__tests__/issues-panel.test.tsx index c3d77960f3a..fa8c8140c7e 100644 --- a/packages/perseus-editor/src/__tests__/issues-panel.test.tsx +++ b/packages/perseus-editor/src/__tests__/issues-panel.test.tsx @@ -23,6 +23,7 @@ const makeIssue = (id: string, impact: IssueImpact = "medium") => ({ help: "Example help", impact, message: "Example message", + type: "Warning" as const, }); describe("IssuesPanel", () => { diff --git a/packages/perseus-editor/src/components/issue-details.tsx b/packages/perseus-editor/src/components/issue-details.tsx index 26b34fbcdd7..e557c729c3b 100644 --- a/packages/perseus-editor/src/components/issue-details.tsx +++ b/packages/perseus-editor/src/components/issue-details.tsx @@ -1,11 +1,12 @@ // WidgetIssueDetails.tsx import {isFeatureOn} from "@khanacademy/perseus-core"; -import {color} from "@khanacademy/wonder-blocks-tokens"; +import {semanticColor} from "@khanacademy/wonder-blocks-tokens"; import {LabelLarge, LabelSmall} from "@khanacademy/wonder-blocks-typography"; import * as React from "react"; import IssueCta from "./issue-cta"; import PerseusEditorAccordion from "./perseus-editor-accordion"; +import ShowMe from "./show-me-issue"; import type {Issue} from "./issues-panel"; import type {APIOptions} from "@khanacademy/perseus"; @@ -19,6 +20,11 @@ const IssueDetails = ({apiOptions, issue}: IssueProps) => { const [expanded, setExpanded] = React.useState(false); const toggleVisibility = () => setExpanded(!expanded); + const accordionColor = + issue.type === "Alert" + ? semanticColor.feedback.critical.subtle.background + : semanticColor.feedback.warning.subtle.background; + // TODO(LEMS-3520): Remove this once the "image-widget-upgrade" feature // flag is has been fully rolled out. Also remove the `apiOptions` prop. const imageUpgradeFF = isFeatureOn({apiOptions}, "image-widget-upgrade"); @@ -28,7 +34,9 @@ const IssueDetails = ({apiOptions, issue}: IssueProps) => { animated={true} expanded={expanded} onToggle={toggleVisibility} - containerStyle={{backgroundColor: color.fadedGold8}} + containerStyle={{ + backgroundColor: accordionColor, + }} panelStyle={{backgroundColor: "white"}} header={ { whiteSpace: "nowrap", }} > - {`Warning: ${issue.id}`} + {`${issue.type}: ${issue.id}`} } > @@ -56,6 +64,7 @@ const IssueDetails = ({apiOptions, issue}: IssueProps) => { Issue: {issue.message} + {imageUpgradeFF && } ); diff --git a/packages/perseus-editor/src/components/issues-panel.tsx b/packages/perseus-editor/src/components/issues-panel.tsx index 4ace3352883..62396ed3a67 100644 --- a/packages/perseus-editor/src/components/issues-panel.tsx +++ b/packages/perseus-editor/src/components/issues-panel.tsx @@ -1,8 +1,9 @@ import {View} from "@khanacademy/wonder-blocks-core"; import {PhosphorIcon} from "@khanacademy/wonder-blocks-icon"; -import {color as wbColor} from "@khanacademy/wonder-blocks-tokens"; +import {semanticColor} from "@khanacademy/wonder-blocks-tokens"; import iconPass from "@phosphor-icons/core/fill/check-circle-fill.svg"; import iconWarning from "@phosphor-icons/core/fill/warning-fill.svg"; +import iconAlert from "@phosphor-icons/core/fill/warning-octagon-fill.svg"; import * as React from "react"; import {useState} from "react"; @@ -12,13 +13,16 @@ import ToggleableCaret from "./toggleable-caret"; import type {APIOptions} from "@khanacademy/perseus"; export type IssueImpact = "low" | "medium" | "high"; +export type IssueType = "Warning" | "Alert"; export type Issue = { id: string; description: string; + elements?: Element[]; helpUrl: string; help: string; impact: IssueImpact; message: string; + type: IssueType; }; type IssuesPanelProps = { @@ -32,12 +36,17 @@ const IssuesPanel = ({apiOptions, issues = []}: IssuesPanelProps) => { const [showPanel, setShowPanel] = useState(false); const hasWarnings = issues.length > 0; + const hasAlerts = issues.some((issue) => issue.type === "Alert"); const issuesCount = `${issues.length} issue${ issues.length === 1 ? "" : "s" }`; - const icon = hasWarnings ? iconWarning : iconPass; - const iconColor = hasWarnings ? wbColor.gold : wbColor.green; + const icon = hasAlerts ? iconAlert : hasWarnings ? iconWarning : iconPass; + const iconColor = hasAlerts + ? semanticColor.feedback.critical.strong.icon + : hasWarnings + ? semanticColor.feedback.warning.strong.icon + : semanticColor.feedback.success.strong.icon; const togglePanel = () => { if (hasWarnings) { @@ -45,6 +54,17 @@ const IssuesPanel = ({apiOptions, issues = []}: IssuesPanelProps) => { } }; + const impactOrder = {high: 3, medium: 2, low: 1}; + const sortedIssues = issues.sort((a, b) => { + if (a.type !== b.type) { + return a.type === "Alert" ? -1 : 1; + } + if (impactOrder[b.impact] !== impactOrder[a.impact]) { + return impactOrder[b.impact] - impactOrder[a.impact]; + } + return a.id.localeCompare(b.id); + }); + return (
@@ -74,7 +94,7 @@ const IssuesPanel = ({apiOptions, issues = []}: IssuesPanelProps) => { {showPanel && (
- {issues.map((issue) => ( + {sortedIssues.map((issue) => ( { + const [showMe, setShowMe] = useState(false); + + if (!elements || elements.length === 0) { + return null; + } + const issueBoundary = elements?.reduce( + (boundary: BoundaryRect, element: Element, index: number) => { + const elementBoundary = element.getBoundingClientRect(); + boundary.top += elementBoundary.top; + boundary.left += elementBoundary.left; + if (index === elements.length - 1) { + boundary.height = elementBoundary.height; + boundary.width = elementBoundary.width; + } + return boundary; + }, + {top: 0, left: 0, height: 0, width: 0}, + ); + const showMeStyle = { + marginTop: "1em", + fontWeight: "bold", + display: "flex", + alignItems: "center", + }; + const showMeOutlineStyle: CSSProperties = + showMe && issueBoundary.width !== 0 + ? { + display: "block", + border: "2px solid red", + borderRadius: "4px", + position: "fixed", + height: issueBoundary.height + 8, + width: issueBoundary.width + 8, + top: issueBoundary.top - 4, + left: issueBoundary.left - 4, + } + : {display: "none"}; + + const showMeToggle = ( + + Show Me + +
+ + ); + const showMeUnavailable = ( +
+ Unable to find the offending element. Please ask a developer for + help fixing this. +
+ ); + + // eslint-disable-next-line + return issueBoundary ? showMeToggle : showMeUnavailable; +}; + +export default ShowMe; diff --git a/packages/perseus-editor/src/iframe-content-renderer.tsx b/packages/perseus-editor/src/iframe-content-renderer.tsx index 8497224edfa..06f9e862e9f 100644 --- a/packages/perseus-editor/src/iframe-content-renderer.tsx +++ b/packages/perseus-editor/src/iframe-content-renderer.tsx @@ -140,6 +140,20 @@ class IframeContentRenderer extends React.Component { frame.style.width = "100%"; frame.style.height = "100%"; frame.src = this.props.url; + // Add axe-core library to the iFrame + frame.onload = () => { + const iframeDoc = + frame.contentDocument || frame.contentWindow?.document; + if (iframeDoc) { + const axeCoreScriptElement = iframeDoc.createElement("script"); + axeCoreScriptElement.src = + "https://unpkg.com/axe-core@4.11.0/axe.js"; + iframeDoc.body.appendChild(axeCoreScriptElement); + } else { + // eslint-disable-next-line no-console + console.warn("Unable to add axe-core to iframe document"); + } + }; if (this.props.datasetKey) { // If the user has specified a data-* attribute to place on the diff --git a/packages/perseus-editor/src/item-editor.tsx b/packages/perseus-editor/src/item-editor.tsx index e10d9318a50..e55314d1dde 100644 --- a/packages/perseus-editor/src/item-editor.tsx +++ b/packages/perseus-editor/src/item-editor.tsx @@ -9,6 +9,7 @@ import Editor from "./editor"; import IframeContentRenderer from "./iframe-content-renderer"; import ItemExtrasEditor from "./item-extras-editor"; import {WARNINGS} from "./messages"; +import {runAxeCoreOnUpdate} from "./util/a11y-checker"; import {ItemEditorContext} from "./util/item-editor-context"; import type {Issue} from "./components/issues-panel"; @@ -49,6 +50,7 @@ type Props = { type State = { issues: Issue[]; + axeCoreIssues: Issue[]; }; class ItemEditor extends React.Component { @@ -63,6 +65,7 @@ class ItemEditor extends React.Component { }; static prevContent: string | undefined; static prevWidgets: PerseusWidgetsMap | undefined; + a11yCheckerTimeoutId: any; frame = React.createRef(); questionEditor = React.createRef(); @@ -70,31 +73,40 @@ class ItemEditor extends React.Component { state = { issues: [], + axeCoreIssues: [], }; - static getDerivedStateFromProps(props: Props): Partial | null { + componentDidUpdate(prevProps: Props) { // Short-circuit if nothing changed if ( - props.question?.content === ItemEditor.prevContent && - props.question?.widgets === ItemEditor.prevWidgets + this.props.question?.content === prevProps.question?.content && + this.props.question?.widgets === prevProps.question?.widgets ) { - return null; + return; } - // Update cached values - ItemEditor.prevContent = props.question?.content; - ItemEditor.prevWidgets = props.question?.widgets; - - const parsed = PerseusMarkdown.parse(props.question?.content ?? "", {}); + const parsed = PerseusMarkdown.parse( + this.props.question?.content ?? "", + {}, + ); const linterContext = { - content: props.question?.content, - widgets: props.question?.widgets, + content: this.props.question?.content, + widgets: this.props.question?.widgets, stack: [], }; - return { - issues: [ - ...(props.issues ?? []), + this.a11yCheckerTimeoutId = runAxeCoreOnUpdate( + this.a11yCheckerTimeoutId, + (issues) => { + this.setState({ + axeCoreIssues: issues, + }); + }, + ); + + const gatherIssues = () => { + return [ + ...(this.props.issues ?? []), ...(PerseusLinter.runLinter(parsed, linterContext, false)?.map( (linterWarning) => { if (linterWarning.rule === "inaccessible-widget") { @@ -109,8 +121,12 @@ class ItemEditor extends React.Component { ); }, ) ?? []), - ], + ]; }; + + this.setState({ + issues: gatherIssues(), + }); } // Notify the parent that the question or answer area has been updated. @@ -155,6 +171,7 @@ class ItemEditor extends React.Component { this.props.deviceType === "phone" || this.props.deviceType === "tablet"; const editingDisabled = this.props.apiOptions?.editingDisabled ?? false; + const allIssues = this.state.issues.concat(this.state.axeCoreIssues); return ( {
Question
@@ -193,7 +210,6 @@ class ItemEditor extends React.Component { />
-
{
-
Question extras
diff --git a/packages/perseus-editor/src/messages.ts b/packages/perseus-editor/src/messages.ts index 91ddcec3e5d..beaf1e4c47c 100644 --- a/packages/perseus-editor/src/messages.ts +++ b/packages/perseus-editor/src/messages.ts @@ -11,6 +11,7 @@ export const WARNINGS = { impact: "medium", message: "Selecting inaccessible widgets for a practice item will result in this exercise being hidden from users with 'Hide visually dependant content' setting set to true. Please select another widget or create an alternative practice item.", + type: "Warning", }), genericLinterWarning: (rule: string, message: string): Issue => ({ @@ -21,5 +22,6 @@ export const WARNINGS = { "https://docs.google.com/document/d/1N13f4sY-7EXWDwQ04ivA9vJBVvPPd60qjBT73B4NHuM/edit?tab=t.0", impact: "low", message: message, + type: "Warning", }), }; diff --git a/packages/perseus-editor/src/styles/perseus-editor.css b/packages/perseus-editor/src/styles/perseus-editor.css index 934c82536a6..bb64a97ac88 100644 --- a/packages/perseus-editor/src/styles/perseus-editor.css +++ b/packages/perseus-editor/src/styles/perseus-editor.css @@ -233,6 +233,8 @@ code { } #perseus { margin: 20px; + width: calc(100% - 500px); + z-index: 10; } #perseus #problemarea #workarea { margin: 0; @@ -257,6 +259,9 @@ code { float: left; padding-bottom: 38px; } +#storybook-root #problemarea { + display: none; +} #problemarea a:link, #problemarea input, #problemarea label, @@ -281,6 +286,8 @@ code { width: 360px; max-width: 360px; min-width: 360px; + position: relative; + z-index: 10; } .perseus-editor-right-cell { box-sizing: border-box; diff --git a/packages/perseus-editor/src/util/a11y-checker.ts b/packages/perseus-editor/src/util/a11y-checker.ts new file mode 100644 index 00000000000..5deec9ea995 --- /dev/null +++ b/packages/perseus-editor/src/util/a11y-checker.ts @@ -0,0 +1,159 @@ +import * as axeCore from "axe-core"; + +import issuesList from "./a11y-issues-list"; + +import type {Issue, IssueType} from "../components/issues-panel"; +import type axe from "axe-core"; + +const assistanceNeededMessage = + "Developer assistance needed - Please send this exercise and warning info to the LEMS team for review."; + +const axeCoreEditorOptions = { + include: { + fromFrames: ['iframe[src^="/perseus/frame"]', "#page-container"], + }, + exclude: { + fromFrames: [ + 'iframe[src^="/perseus/frame"]', + '[target="lint-help-window"]', + ], + }, +}; +const axeCoreStorybookOptions = { + include: ["#preview-panel"], + exclude: ['[target="lint-help-window"]'], +}; + +const convertAxeImpactToIssueImpact = ( + impact?: axe.ImpactValue, +): Issue["impact"] => { + switch (impact) { + case "critical": + return "high"; + case "serious": + return "high"; + case "moderate": + return "medium"; + case "minor": + return "low"; + default: + return "low"; + } +}; + +const getIssueMessage = (nodes: axe.NodeResult[]): string => { + return nodes + .map((node) => { + return node.all + .concat(node.any, node.none) + .map((result) => result.message) + .join(" "); + }) + .join(" "); +}; + +const getIssueElements = (nodes: axe.NodeResult[]): Element[] => { + const nodeToCheck = nodes.length > 0 ? [nodes[0]] : []; + // @ts-expect-error TS2322: Type 'string[]' is not assignable to type 'Element[]'. + return nodeToCheck.flatMap((node) => { + // @ts-expect-error TS2769: No overload matches this call. + return node.target.reduce((elements: Element[], target: string) => { + let element: Element | null; + if ( + elements.length > 0 && + elements[elements.length - 1].tagName.toLowerCase() === "iframe" + ) { + element = + // @ts-expect-error TS2551: Property 'contentDocument' does not exist on type 'Element' + elements[elements.length - 1].contentDocument.querySelector( + target, + ); + } else { + element = document.querySelector(target); + } + if (element) { + elements.push(element); + } + return elements; + }, []); + }); +}; + +const mapResultsToIssues = ( + results: axe.Result[], + type: IssueType, +): Issue[] => { + return results.map((result) => { + const isUserFixable = + type === "Alert" && + issuesList["axe-core"]["user-fixable"].some( + (testId) => testId === result.id, + ); + const description = isUserFixable ? "" : assistanceNeededMessage; + return { + id: result.id, + description: description, + elements: getIssueElements(result.nodes), + helpUrl: result.helpUrl, + help: result.help, + impact: convertAxeImpactToIssueImpact(result.impact), + message: getIssueMessage(result.nodes), + type: type, + }; + }); +}; + +const runAxeCore = (updateIssuesFn: (issues: Issue[]) => void): void => { + const isInStorybook = !!document.getElementById("storybook-root"); + if (!isInStorybook) { + let frameHasLoaded = false; + const frame = document.querySelector('iframe[src^="/perseus/frame"]'); + if (frame) { + const frameDocument = + // @ts-expect-error TS2551: Property 'contentDocument' does not exist on type 'Element'. + frame.contentDocument || frame.contentWindow?.document; + frameHasLoaded = frameDocument?.readyState === "complete"; + } + if (!frameHasLoaded) { + setTimeout(runAxeCore, 100, updateIssuesFn); + return; + } + } + const options = isInStorybook + ? axeCoreStorybookOptions + : axeCoreEditorOptions; + axeCore.configure({reporter: "v2"}); + // @ts-expect-error TS2769: No overload matches this call. + axeCore.run(options).then( + (results) => { + // eslint-disable-next-line no-console + console.log(`Accessibility Results: `, results); + const violations = mapResultsToIssues(results.violations, "Alert"); + const incompletes = mapResultsToIssues( + results.incomplete, + "Warning", + ); + const issues = violations.concat(incompletes); + // eslint-disable-next-line no-console + console.log(` Issues: `, issues); + if ( + violations.length === 0 && + incompletes.length === 0 && + results.passes.length === 0 + ) { + setTimeout(runAxeCore, 1500, updateIssuesFn); // No valid results indicates that content may not be fully loaded yet + } else { + updateIssuesFn(issues); + } + }, + (error) => { + // eslint-disable-next-line no-console + console.log(` Error: `, error); + }, + ); +}; + +export const runAxeCoreOnUpdate = (priorTimeoutId, setState): any => { + clearTimeout(priorTimeoutId); + return setTimeout(runAxeCore, 1500, setState); +}; diff --git a/packages/perseus-editor/src/util/a11y-issues-list.ts b/packages/perseus-editor/src/util/a11y-issues-list.ts new file mode 100644 index 00000000000..54f4d82b8de --- /dev/null +++ b/packages/perseus-editor/src/util/a11y-issues-list.ts @@ -0,0 +1,19 @@ +export default { + "axe-core": { + "user-fixable": [ + "aria-input-field-name", + "aria-meter-name", + "aria-toggle-field-name", + "image-alt", + "input-image-alt", + "label", + "link-name", + "list", + "role-img-alt", + "select-name", + "summary-name", + "svg-img-alt", + "video-caption", + ], + }, +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 67c8c1e3c81..207f09f44f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -827,6 +827,9 @@ importers: '@khanacademy/perseus-utils': specifier: workspace:* version: link:../perseus-utils + axe-core: + specifier: ^4.11.0 + version: 4.11.0 katex: specifier: 0.11.1 version: 0.11.1 @@ -3789,6 +3792,10 @@ packages: resolution: {integrity: sha512-RE3mdQ7P3FRSe7eqCWoeQ/Z9QXrtniSjp1wUjt5nRC3WIpz5rSCve6o3fsZ2aCpJtrZjSZgjwXAoTO5k4tEI0w==} engines: {node: '>=4'} + axe-core@4.11.0: + resolution: {integrity: sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ==} + engines: {node: '>=4'} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -12766,6 +12773,8 @@ snapshots: axe-core@4.10.2: {} + axe-core@4.11.0: {} + axobject-query@4.1.0: {} babel-jest@29.7.0(@babel/core@7.26.0): @@ -17529,7 +17538,7 @@ snapshots: react-router-dom@5.3.4(react@18.2.0): dependencies: - '@babel/runtime': 7.25.4 + '@babel/runtime': 7.27.1 history: 4.10.1 loose-envify: 1.4.0 prop-types: 15.8.1 @@ -17540,7 +17549,7 @@ snapshots: react-router@5.3.4(react@18.2.0): dependencies: - '@babel/runtime': 7.25.4 + '@babel/runtime': 7.27.1 history: 4.10.1 hoist-non-react-statics: 3.3.2 loose-envify: 1.4.0 @@ -17576,7 +17585,7 @@ snapshots: react-window@1.8.10(react-dom@18.2.0(react@18.2.0))(react@18.2.0): dependencies: - '@babel/runtime': 7.25.4 + '@babel/runtime': 7.27.1 memoize-one: 5.2.1 react: 18.2.0 react-dom: 18.2.0(react@18.2.0)