Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow keyboard navigation of images #1990

Merged
merged 6 commits into from
Jan 6, 2025
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/itchy-phones-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@khanacademy/perseus": minor
---

Allow keyboards to navigate and interact with images
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ exports[`SvgImage should load and render a localized graphie svg 1`] = `
<img
alt="svg image"
src="mockStaticUrl(https://ka-perseus-graphie.s3.amazonaws.com/ccefe63aa1bd05f1d11123f72790a49378d2e42b.svg)"
tabindex="0"
/>
</div>
</div>
Expand All @@ -21,6 +22,7 @@ exports[`SvgImage should load and render a normal graphie svg 1`] = `
<img
alt="svg image"
src="mockStaticUrl(https://ka-perseus-graphie.s3.amazonaws.com/ccefe63aa1bd05f1d11123f72790a49378d2e42b.svg)"
tabindex="0"
/>
</div>
</div>
Expand All @@ -31,6 +33,7 @@ exports[`SvgImage should load and render a png 1`] = `
<img
alt="png image"
src="mockStaticUrl(http://localhost/sample.png)"
tabindex="0"
/>
</div>
`;
Expand Down
1 change: 1 addition & 0 deletions packages/perseus/src/components/image-loader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ class ImageLoader extends React.Component<Props, State> {

return (
<img
tabIndex={0}
src={staticUrl(src)}
onKeyUp={onKeyUp}
onKeyDown={onKeyDown}
Expand Down
58 changes: 58 additions & 0 deletions packages/perseus/src/widgets/image/image.cypress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import renderQuestionWithCypress from "../../../../../testing/render-question-with-cypress";
import {cypressTestDependencies} from "../../../../../testing/test-dependencies";
import * as Dependencies from "../../dependencies";
import * as Perseus from "../../index";

import {questionWithZoom} from "./image.testdata";

// NOTE: The regression tests in this file use Cypress because they are intended to validate styling that is applied.
// Since React Testing Library isn't applying the CSS to the elements,
// we can't use Jest to verify that some keyboard interactions work properly.

describe("Image Widget", () => {
beforeEach(() => {
Dependencies.setDependencies(cypressTestDependencies);
Perseus.init();
window.innerWidth = 1024;
});

afterEach(() => {
// remove classes that are added to the body
cy.get("body").invoke("removeClass", "zoom-overlay-open");
cy.get("img.zoom-img").invoke("remove");
});

it("opens and closes zoomable images on click", () => {
// Arrange
renderQuestionWithCypress(questionWithZoom);

// Act - click on the image
cy.get(".zoomable img").click();

// Assert
// The zoomed image should be visible and the zoomable image should be hidden
cy.get("img.zoom-img").should("be.visible");
cy.get(".zoomable img").should("not.be.visible");

cy.get(".zoom-img").click();

// Assert - the zoomable image should be hidden
cy.get("img.zoom-img").should("not.be.visible");
});

it("opens and closes on keyboard interaction", () => {
// Arrange
renderQuestionWithCypress(questionWithZoom);

// Act - focus on the zoomable image and press enter
cy.get(".zoomable img").focus().type("{enter}");

// Assert
// The zoomed image should be visible and the zoomable image should be hidden
cy.get("img.zoom-img").should("be.visible");
cy.get(".zoomable img").should("not.be.visible");

// Act - focus on the zoomed image and press escape
cy.get("img.zoom-img").focus().type("{esc}");
});
});
32 changes: 31 additions & 1 deletion packages/perseus/src/widgets/image/image.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import * as React from "react";

import {RendererWithDebugUI} from "../../../../../testing/renderer-with-debug-ui";

import {question} from "./image.testdata";
import {question, questionWithZoom} from "./image.testdata";

import type {APIOptions} from "../../types";

Expand Down Expand Up @@ -76,6 +76,36 @@ export const Question2 = (args: StoryArgs): React.ReactElement => {
);
};

export const ImageWithZoom = (args: StoryArgs): React.ReactElement => {
const apiOptions: APIOptions = {
isMobile: args.isMobile,
};
const imageOptions = questionWithZoom.widgets["image 1"].options;

const questionWithCaptionAndArgs = {
...questionWithZoom,
widgets: {
...questionWithZoom.widgets,
"image 1": {
...questionWithZoom.widgets["image 1"],
options: {
...imageOptions,
alignment: "full-width",
title: args.title,
caption:
"There is neither happiness nor unhappiness in this world; there is only the comparison of one state with another. Only a man who has felt ultimate despair is capable of feeling ultimate bliss. It is necessary to have wished for death in order to know how good it is to live.....the sum of all human wisdom will be contained in these two words: Wait and Hope",
},
},
},
} as const;
return (
<RendererWithDebugUI
question={questionWithCaptionAndArgs}
apiOptions={apiOptions}
/>
);
};

export default {
title: "Perseus/Widgets/Image",
args: {
Expand Down
35 changes: 35 additions & 0 deletions packages/perseus/src/widgets/image/image.testdata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,38 @@ export const question = {
} as ImageWidget,
},
} as const;

export const questionWithZoom = {
content:
"[[☃ image 1]]\n\n=====\n\nA quilter wants to make the design shown at left using the Golden Ratio. Specifically, he wants the ratio of the triangle heights $A:B$ and $B:C$ to each equal $1.62$. If the quilter makes the triangle height $A=8\\ \\text{in}$, approximately how tall should he make triangle height $C$?",
images: {
"https://cdn.kastatic.org/ka-perseus-images/01f44d5b73290da6bec97c75a5316fb05ab61f12.jpg":
{height: 955, width: 1698},
},
widgets: {
"image 1": {
alignment: "block",
graded: true,
options: {
alt: "An array of isosceles triangles. A triangle has height A. Two smaller triangle, one with height B and one with height C, have approximately the same combined height as A.",
title: "Image Title",
caption: "Image Caption",
backgroundImage: {
height: 955,
url: "https://cdn.kastatic.org/ka-perseus-images/01f44d5b73290da6bec97c75a5316fb05ab61f12.jpg",
width: 1698,
},
box: [1698, 955],
labels: [],
range: [
[0, 10],
[0, 10],
],
static: false,
},
static: false,
type: "image",
version: {major: 0, minor: 0},
} as ImageWidget,
},
} as const;
6 changes: 5 additions & 1 deletion packages/perseus/src/zoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,7 +265,9 @@ ZoomServiceClass.prototype._scrollHandler = function (e: any) {
};

ZoomServiceClass.prototype._keyHandler = function (e: any) {
if (e.keyCode === 27) {
// 27: Esc, 13: Enter, 32: Space
const keyCodes = [27, 13, 32];
if (keyCodes.includes(e.keyCode)) {
this._activeZoomClose();
}
};
Expand Down Expand Up @@ -367,12 +369,14 @@ Zoom.prototype.zoomImage = function () {

img.src = this._targetImage.src;
img.alt = this._targetImage.alt;
img.tabIndex = 0;

this.$zoomedImage = $zoomedImage;
};

Zoom.prototype._zoomOriginal = function () {
this.$zoomedImage.addClass("zoom-img").attr("data-action", "zoom-out");

$(this._targetImage).css("visibility", "hidden");

this._backdrop = document.createElement("div");
Expand Down
Loading