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
5 changes: 5 additions & 0 deletions .changeset/giant-donkeys-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": patch
---

[Image] | (a11y) | Add aria-describedby to Explore Image modal
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import type {CommonImageProps, ZoomProps, GifProps} from "./image-info-area";

const MODAL_HEIGHT = 568;

type Props = CommonImageProps & ZoomProps & GifProps;
type Props = CommonImageProps &
ZoomProps &
GifProps & {
captionId: string;
longDescId: string;
};

export default function ExploreImageModalContent({
backgroundImage,
Expand All @@ -30,6 +35,8 @@ export default function ExploreImageModalContent({
labels,
range,
zoomSize,
captionId,
longDescId,
}: Props) {
const [isGifPlaying, setIsGifPlaying] = React.useState(false);
const context = React.useContext(PerseusI18nContext);
Expand Down Expand Up @@ -146,7 +153,10 @@ export default function ExploreImageModalContent({
)}

{caption && (
<div className={styles.modalCaptionContainer}>
<div
id={captionId}
className={styles.modalCaptionContainer}
>
{/* Use Renderer so that the caption can support markdown and TeX. */}
<Renderer
content={caption}
Expand All @@ -165,12 +175,14 @@ export default function ExploreImageModalContent({
{context.strings.imageDescriptionLabel}
</Heading>
{/* Use Renderer so that the description can support markdown and TeX. */}
<Renderer
content={longDescription}
apiOptions={apiOptions}
linterContext={linterContext}
strings={context.strings}
/>
<div id={longDescId}>
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does Renderer not have an id property? That's annoying! If you have enough time it think it would be worth while to add it. But non-blocking.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this might be the first time I've seen a Renderer linked to an aria-describedby. Although we could drill the id down to the <div> that the Renderer renders, I'm not sure it feels warranted for only one use. Perhaps we could wait and see if this comes up again?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hypothetically, one could argue this is another example of a Renderer wrapped in something with an ID: https://github.com/Khan/perseus/blob/main/packages/perseus/src/widgets/numeric-input/input-with-examples.tsx#L143-L154

It looks like it also needs that wrapper for the classname, so I'm not sure if that's a valid example for this. I'm inclined to leave the code as is for now, since I don't currenly have a way to test that any changes here would be valid (I'd need a windows machine with NVDA).

<Renderer
content={longDescription}
apiOptions={apiOptions}
linterContext={linterContext}
strings={context.strings}
/>
</div>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,38 @@ describe("ExploreImageModal", () => {
expect(screen.getByText("widget long description")).toBeInTheDocument();
});

it("sets the describedby to short and long description IDs if there is a caption", () => {
// Arrange, Act
renderModal({
...defaultProps,
backgroundImage: earthMoonImage,
caption: "widget caption",
});

// Assert
const dialog = screen.getByRole("dialog");
// Regex for "uniqueId-caption uniqueId-long-desc"
expect(dialog.getAttribute("aria-describedby")).toMatch(
/^:r\w+:-caption :r\w+:-long-desc$/,
);
});

it("sets the describedby to long description ID if there is no caption", () => {
// Arrange, Act
renderModal({
...defaultProps,
backgroundImage: earthMoonImage,
longDescription: "widget long description",
});

// Assert
const dialog = screen.getByRole("dialog");
const ariaDescribedBy = dialog.getAttribute("aria-describedby");
// Regex for "uniqueId-long-desc"
expect(ariaDescribedBy).toMatch(/^:r\w+:-long-desc$/);
expect(ariaDescribedBy).not.toMatch(/caption/);
});

describe("gif controls", () => {
it("should render gif controls if the image is a gif", () => {
// Arrange, Act
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ type Props = CommonImageProps & ZoomProps & GifProps;

export const ExploreImageModal = (props: Props) => {
const context = React.useContext(PerseusI18nContext);
const uniqueId = React.useId();
const captionId = `${uniqueId}-caption`;
const longDescId = `${uniqueId}-long-desc`;

const titleText = props.title || context.strings.imageAlternativeTitle;
const title = (
Expand All @@ -39,7 +42,16 @@ export const ExploreImageModal = (props: Props) => {
>
<FlexibleDialog
title={title}
content={<ExploreImageModalContent {...props} />}
content={
<ExploreImageModalContent
{...props}
captionId={captionId}
longDescId={longDescId}
/>
}
aria-describedby={
props.caption ? `${captionId} ${longDescId}` : longDescId
}
styles={{
root: wbStyles.root,
}}
Expand Down
Loading