Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
315071d
[LEMS-3822-add-a11y-checker-to-exercise-editor] Add A11y checking fun…
mark-fitzgerald Dec 31, 2025
bd66848
[LEMS-3822-add-a11y-checker-to-exercise-editor] docs(changeset): Add …
mark-fitzgerald Jan 6, 2026
e4241af
[LEMS-3822-add-a11y-checker-to-exercise-editor] Convert regular butto…
mark-fitzgerald Jan 6, 2026
972ee9c
[LEMS-3822-add-a11y-checker-to-exercise-editor] Filter the issue mess…
mark-fitzgerald Jan 6, 2026
1b34106
[LEMS-3822-add-a11y-checker-to-exercise-editor] Adjust query selector…
mark-fitzgerald Jan 6, 2026
e314720
[LEMS-3822-add-a11y-checker-to-exercise-editor] Adjust iframe selector.
mark-fitzgerald Jan 6, 2026
7c65f5f
[LEMS-3822-add-a11y-checker-to-exercise-editor] Correct query selecto…
mark-fitzgerald Jan 7, 2026
a6af800
[LEMS-3822-add-a11y-checker-to-exercise-editor] Add debugging input e…
mark-fitzgerald Jan 8, 2026
8e7a3e6
[LEMS-3822-add-a11y-checker-to-exercise-editor] Adjust axe-core call …
mark-fitzgerald Jan 9, 2026
7e642f4
[LEMS-3822-add-a11y-checker-to-exercise-editor] Account for iframe co…
mark-fitzgerald Jan 9, 2026
5b4c017
[LEMS-3822-add-a11y-checker-to-exercise-editor] Merge branch 'main' i…
mark-fitzgerald Jan 9, 2026
05e12c3
[LEMS-3822-add-a11y-checker-to-exercise-editor] Handle multiple eleme…
mark-fitzgerald Jan 10, 2026
d242d5b
[LEMS-3822-add-a11y-checker-to-exercise-editor] Debug messages
mark-fitzgerald Jan 14, 2026
3a23d90
[LEMS-3822-add-a11y-checker-to-exercise-editor] Remove debug statements.
mark-fitzgerald Jan 14, 2026
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
5 changes: 5 additions & 0 deletions .changeset/fuzzy-lies-help.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus-editor": patch
---

Add accessibility checker (axe-core) in the exercise editor
1 change: 1 addition & 0 deletions packages/perseus-editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
29 changes: 14 additions & 15 deletions packages/perseus-editor/src/components/issue-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ 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";
Expand All @@ -25,6 +26,16 @@ const IssueDetails = ({apiOptions, issue}: IssueProps) => {
const [expanded, setExpanded] = React.useState(false);
const toggleVisibility = () => setExpanded(!expanded);

const accordionColor =
issue.impact === "high"
? semanticColor.feedback.critical.subtle.background
: semanticColor.feedback.warning.subtle.background;
const messageStyling = {
// Allow newlines in the message
whiteSpace: "pre-line",
color: semanticColor.core.foreground.critical.subtle,
};

// 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");
Expand All @@ -34,12 +45,7 @@ const IssueDetails = ({apiOptions, issue}: IssueProps) => {
animated={true}
expanded={expanded}
onToggle={toggleVisibility}
containerStyle={{
backgroundColor:
issue.impact === "high"
? semanticColor.feedback.critical.subtle.background
: semanticColor.feedback.warning.subtle.background,
}}
containerStyle={{backgroundColor: accordionColor}}
panelStyle={{backgroundColor: "white"}}
header={
<LabelLarge
Expand All @@ -66,15 +72,8 @@ const IssueDetails = ({apiOptions, issue}: IssueProps) => {
<LabelSmall style={{marginTop: "1em", fontWeight: "bold"}}>
Issue:
</LabelSmall>
<span
style={{
// Allow newlines in the message
whiteSpace: "pre-line",
color: semanticColor.core.foreground.critical.subtle,
}}
>
{issue.message}
</span>
<span style={messageStyling}>{issue.message}</span>
<ShowMe elements={issue.elements} />
{imageUpgradeFF && <IssueCta issue={issue} />}
</PerseusEditorAccordion>
);
Expand Down
43 changes: 38 additions & 5 deletions packages/perseus-editor/src/components/issues-panel.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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";

import IssueDetails from "./issue-details";
import LabeledSwitch from "./labeled-switch";
import ToggleableCaret from "./toggleable-caret";

import type {APIOptions} from "@khanacademy/perseus";
Expand All @@ -15,6 +17,7 @@ export type IssueImpact = "low" | "medium" | "high";
export type Issue = {
id: string;
description: string;
elements?: Element[];
helpUrl: string;
help: string;
impact: IssueImpact;
Expand All @@ -26,18 +29,40 @@ type IssuesPanelProps = {
// "image-widget-upgrade" feature flag is has been fully rolled out.
apiOptions?: APIOptions;
issues?: Issue[];
a11yCheck?: {
callback: () => void;
isChecked: boolean;
};
};

const IssuesPanel = ({apiOptions, issues = []}: IssuesPanelProps) => {
const IssuesPanel = (props: IssuesPanelProps) => {
const {apiOptions, issues = []} = props;
const a11yCheck = props.a11yCheck || {
callback: () => {},
isChecked: false,
};
const [showPanel, setShowPanel] = useState(false);

const hasWarnings = issues.length > 0;
const hasErrors = issues.some((issue) => issue.impact === "high");
const issuesCount = `${issues.length} issue${
issues.length === 1 ? "" : "s"
}`;

const icon = hasWarnings ? iconWarning : iconPass;
const iconColor = hasWarnings ? wbColor.gold : wbColor.green;
const icon = hasErrors ? iconAlert : hasWarnings ? iconWarning : iconPass;
const iconColor = hasErrors
? semanticColor.feedback.critical.strong.icon
: hasWarnings
? semanticColor.feedback.warning.strong.icon
: semanticColor.feedback.success.strong.icon;

const impactOrder = {high: 3, medium: 2, low: 1};
const sortedIssues = issues.sort((a, b) => {
if (impactOrder[b.impact] !== impactOrder[a.impact]) {
return impactOrder[b.impact] - impactOrder[a.impact];
}
return a.id.localeCompare(b.id);
});

return (
<div className="perseus-widget-editor">
Expand Down Expand Up @@ -68,14 +93,22 @@ const IssuesPanel = ({apiOptions, issues = []}: IssuesPanelProps) => {
{showPanel && (
<div className="perseus-widget-editor-panel">
<div className="perseus-widget-editor-content">
{issues.map((issue) => (
{sortedIssues.map((issue) => (
<IssueDetails
apiOptions={apiOptions}
key={issue.id}
issue={issue}
/>
))}
{issues.length === 0 && <div>No issues found</div>}
<LabeledSwitch
label="Include axe-core scan"
checked={a11yCheck.isChecked}
onChange={() => {
a11yCheck.callback();
}}
style={{marginBlockStart: "1rem"}}
/>
</div>
</div>
)}
Expand Down
78 changes: 78 additions & 0 deletions packages/perseus-editor/src/components/show-me-issue.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import Switch from "@khanacademy/wonder-blocks-switch";
import {LabelSmall} from "@khanacademy/wonder-blocks-typography";
import * as React from "react";
import {useState} from "react";

import type {CSSProperties} from "react";

type BoundaryRect = {
top: number;
left: number;
height: number;
width: number;
};

const ShowMe = ({elements}: {elements?: Element[]}) => {
const [showMe, setShowMe] = useState(false);

if (!elements || elements.length === 0) {
return null;
}
const getIssueBoundary = (element: Element): BoundaryRect => {
const iframeBoundary =
element.ownerDocument.defaultView?.frameElement?.getBoundingClientRect();
const elementBoundary = element.getBoundingClientRect();
return {
top: elementBoundary.top + (iframeBoundary?.top || 0),
left: elementBoundary.left + (iframeBoundary?.left || 0),
height: elementBoundary.height,
width: elementBoundary.width,
};
};
const showMeStyle = {
marginTop: "1em",
fontWeight: "bold",
display: "flex",
alignItems: "center",
};
const getOutlineStyle = (issueBoundary: BoundaryRect): 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 elementOutlines = elements?.map((element, index) => {
const issueBoundary = getIssueBoundary(element);
const outlineStyle = getOutlineStyle(issueBoundary);
return <div key={index} style={outlineStyle} />;
});

const showMeToggle = (
<LabelSmall style={showMeStyle}>
<span style={{marginInlineEnd: "1em"}}>Show Me</span>
<Switch checked={showMe} onChange={setShowMe} />
{elementOutlines}
</LabelSmall>
);
const showMeUnavailable = (
<div>
Unable to find the offending element. Please ask a developer for
help fixing this.
</div>
);

// eslint-disable-next-line
return Array.isArray(elementOutlines) && elementOutlines.length > 0
? showMeToggle
: showMeUnavailable;
};

export default ShowMe;
15 changes: 15 additions & 0 deletions packages/perseus-editor/src/iframe-content-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,22 @@ class IframeContentRenderer extends React.Component<Props> {
const frame = document.createElement("iframe");
frame.style.width = "100%";
frame.style.height = "100%";
frame.dataset.name = "content-preview";
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/[email protected]/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
Expand Down
64 changes: 47 additions & 17 deletions packages/perseus-editor/src/item-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {detectTexErrors} from "./util/tex-error-detector";

Expand Down Expand Up @@ -50,6 +51,8 @@ type Props = {

type State = {
issues: Issue[];
axeCoreIssues: Issue[];
showAxeCoreIssues: boolean;
};

class ItemEditor extends React.Component<Props, State> {
Expand All @@ -64,44 +67,55 @@ class ItemEditor extends React.Component<Props, State> {
};
static prevContent: string | undefined;
static prevWidgets: PerseusWidgetsMap | undefined;
a11yCheckerTimeoutId: any;

frame = React.createRef<IframeContentRenderer>();
questionEditor = React.createRef<Editor>();
itemExtrasEditor = React.createRef<ItemExtrasEditor>();

state = {
issues: [],
axeCoreIssues: [],
showAxeCoreIssues: false,
};

static getDerivedStateFromProps(props: Props): Partial<State> | 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: [],
};

// Detect TeX errors
const texErrors = detectTexErrors(props.question?.content ?? "");
const texErrors = detectTexErrors(this.props.question?.content ?? "");
const texIssues = texErrors.map((error, index) =>
WARNINGS.texError(error.math, error.message, index),
);

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") {
Expand All @@ -118,8 +132,12 @@ class ItemEditor extends React.Component<Props, State> {
},
) ?? []),
...texIssues,
],
];
};

this.setState({
issues: gatherIssues(),
});
}

// Notify the parent that the question or answer area has been updated.
Expand Down Expand Up @@ -164,6 +182,17 @@ class ItemEditor extends React.Component<Props, State> {
this.props.deviceType === "phone" ||
this.props.deviceType === "tablet";
const editingDisabled = this.props.apiOptions?.editingDisabled ?? false;
const allIssues = this.state.issues.concat(
this.state.showAxeCoreIssues ? this.state.axeCoreIssues : [],
);
const a11yCheck = {
callback: () => {
this.setState({
showAxeCoreIssues: !this.state.showAxeCoreIssues,
});
},
isChecked: this.state.showAxeCoreIssues,
};

return (
<ItemEditorContext.Provider
Expand All @@ -177,7 +206,8 @@ class ItemEditor extends React.Component<Props, State> {
<div className="perseus-editor-left-cell">
<IssuesPanel
apiOptions={this.props.apiOptions}
issues={this.state.issues}
issues={allIssues}
a11yCheck={a11yCheck}
/>
<div className="pod-title">Question</div>
<fieldset disabled={editingDisabled}>
Expand Down
Loading
Loading