Skip to content

Commit dae8b1d

Browse files
authored
[Image] | (a11y) | Update zoom view SRUX (#3426)
## Summary: We needed to make some updates to the button structure for the zoom view to improve accessibility: - Update the "Zoom image" string to say "Make image bigger" for clarity - Update the "Reset zoom" string to say "Close image" for clarity - Remove the close button that was overlayed over the entire view (and was making the image undiscoverable, because the image was a child of the button) - Add a close button in the top right corner of the zoom view - It should be invisible by default - It should become visible when the corner is hovered over - It should become visible when receiving focus (i.e. when someone tabs to it) Issue: https://khanacademy.atlassian.net/browse/LEMS-3999 ## Test plan: `pnpm jest packages/perseus/src/widgets/image/image.test.ts` Storybook - `/?path=/story/widgets-image-visual-regression-tests-interactions--zoom-clicked-state` | Initial view | Hovered | Focused | | --- | --- | --- | | <img width="538" height="351" alt="Screenshot 2026-03-27 at 2 20 43 PM" src="https://github.com/user-attachments/assets/78f3af6f-fc9f-4805-b4ac-c5ceb53b4bdb" /> | <img width="538" height="329" alt="Screenshot 2026-03-27 at 2 20 47 PM" src="https://github.com/user-attachments/assets/5b634abc-c512-43be-b21f-f1406d85edca" /> | <img width="513" height="322" alt="Screenshot 2026-03-27 at 2 20 50 PM" src="https://github.com/user-attachments/assets/b90c5205-cac0-454c-92c5-aa5c430d45b5" /> | Author: nishasy Reviewers: claude[bot], catandthemachines, ivyolamit Required Reviewers: Approved By: catandthemachines Checks: ✅ 10 checks were successful, ⏭️ 1 check has been skipped Pull Request URL: #3426
1 parent 06ac0a1 commit dae8b1d

File tree

10 files changed

+128
-89
lines changed

10 files changed

+128
-89
lines changed

.changeset/tame-buses-matter.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@khanacademy/perseus": patch
3+
---
4+
5+
[Image] | (a11y) | Update zoom view SRUX

packages/perseus/src/components/zoom-image-button.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ export const ZoomImageButton = (props: Props) => {
1515
const {imgSrc} = props;
1616

1717
const i18n = usePerseusI18n();
18+
// Remove the colons from the React-generated unique ID so that it
19+
// can be used as ModalLauncher's initialFocusId. ModalLauncher uses
20+
// querySelector to find the element to focus, and unescaped colons
21+
// are treated as pseudo-class selectors in CSS, causing an error.
22+
const uniqueId = React.useId().replace(/:/g, "");
23+
const zoomedImageUniqueId = `zoomed-image-${uniqueId}`;
1824

1925
// Check for "Command + Click" or "Control + Click" to open the image
2026
// in a new tab. This feature was part of the old zoom service, so
@@ -33,8 +39,13 @@ export const ZoomImageButton = (props: Props) => {
3339

3440
return (
3541
<ModalLauncher
42+
initialFocusId={zoomedImageUniqueId}
3643
modal={({closeModal}) => (
37-
<ZoomedImageView {...props} onClose={closeModal} />
44+
<ZoomedImageView
45+
{...props}
46+
initialFocusId={zoomedImageUniqueId}
47+
onClose={closeModal}
48+
/>
3849
)}
3950
>
4051
{({openModal}) => (

packages/perseus/src/components/zoomed-image-view.module.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
margin-inline: calc(-1 * var(--wb-c-modal-panel-layout-gap-default));
55
/* Remove inline spacing that creates a 1px gap below images */
66
line-height: 0;
7+
/* Add relative position so that the close button child can be absolutely positioned. */
8+
position: relative;
79
}
810

911
.imageContainer {

packages/perseus/src/components/zoomed-image-view.tsx

Lines changed: 61 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import {isFeatureOn} from "@khanacademy/perseus-core";
2-
import Clickable from "@khanacademy/wonder-blocks-clickable";
2+
import IconButton from "@khanacademy/wonder-blocks-icon-button";
33
import {ModalDialog, ModalPanel} from "@khanacademy/wonder-blocks-modal";
4-
import {sizing} from "@khanacademy/wonder-blocks-tokens";
4+
import {border, sizing} from "@khanacademy/wonder-blocks-tokens";
5+
import closeIcon from "@phosphor-icons/core/bold/x-bold.svg";
56
import {StyleSheet} from "aphrodite";
67
import * as React from "react";
78

@@ -14,6 +15,8 @@ import styles from "./zoomed-image-view.module.css";
1415
import type {Props as SvgImageProps} from "./svg-image";
1516

1617
interface Props extends SvgImageProps {
18+
// ID to use for the
19+
initialFocusId: string;
1720
onClose: () => void;
1821
}
1922

@@ -24,7 +27,7 @@ export const ZoomedImageView = (props: Props) => {
2427
"image-widget-upgrade-scale",
2528
);
2629

27-
const {onClose, ...svgProps} = props;
30+
const {initialFocusId, onClose, ...svgProps} = props;
2831
const width = props.width;
2932
const contentScale = props.scale;
3033

@@ -52,46 +55,52 @@ export const ZoomedImageView = (props: Props) => {
5255
closeButtonVisible={false}
5356
content={
5457
<div className={styles.contentWrapper}>
55-
<Clickable
56-
onClick={onClose}
57-
aria-label={i18n.strings.imageResetZoomAriaLabel}
58+
<div
59+
className={styles.imageContainer}
60+
// This wrapper's explicit width tells
61+
// the image how big it should be.
62+
// Without it, the auto-width modal
63+
// causes the image to collapse to its
64+
// natural pixel size, ignoring scale.
5865
style={{
59-
cursor: "zoom-out",
66+
width:
67+
width && scaleFF
68+
? width * scale
69+
: undefined,
6070
}}
6171
>
62-
{() => (
63-
// This wrapper's explicit width tells
64-
// the image how big it should be.
65-
// Without it, the auto-width modal
66-
// causes the image to collapse to its
67-
// natural pixel size, ignoring scale.
68-
<div
69-
className={styles.imageContainer}
70-
style={{
71-
width:
72-
width && scaleFF
73-
? width * scale
74-
: undefined,
75-
}}
76-
>
77-
<div
78-
// We need to include the framework-perseus
79-
// class here to ensure that the image is
80-
// styled correctly. Otherwise the Graphie
81-
// labels may not be in the correct positions.
82-
className="framework-perseus"
83-
>
84-
<SvgImage
85-
{...svgProps}
86-
// Don't allow zooming inside the
87-
// zoom view.
88-
allowZoom={false}
89-
scale={scaleFF ? scale : 1}
90-
/>
91-
</div>
92-
</div>
93-
)}
94-
</Clickable>
72+
<div
73+
// We need to include the framework-perseus
74+
// class here to ensure that the image is
75+
// styled correctly. Otherwise the Graphie
76+
// labels may not be in the correct positions.
77+
className="framework-perseus"
78+
// tabIndex={0} makes this a focus target so that:
79+
// 1. ModalLauncher's initialFocusId can focus it on
80+
// open, keeping the close button hidden initially.
81+
// 2. Tab can move focus here from the close button,
82+
// causing the button to hide without closing the
83+
// modal.
84+
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
85+
tabIndex={0}
86+
id={initialFocusId}
87+
>
88+
<SvgImage
89+
{...svgProps}
90+
// Don't allow zooming inside the
91+
// zoom view.
92+
allowZoom={false}
93+
scale={scaleFF ? scale : 1}
94+
/>
95+
</div>
96+
</div>
97+
<IconButton
98+
icon={closeIcon}
99+
onClick={onClose}
100+
aria-label={i18n.strings.imageResetZoomAriaLabel}
101+
kind="primary"
102+
style={wbStyles.closeButton}
103+
/>
95104
</div>
96105
}
97106
/>
@@ -114,4 +123,16 @@ const wbStyles = StyleSheet.create({
114123
margin: 0,
115124
},
116125
},
126+
closeButton: {
127+
position: "absolute",
128+
top: border.width.medium,
129+
right: border.width.medium,
130+
opacity: 0,
131+
":hover": {
132+
opacity: 1,
133+
},
134+
":focus": {
135+
opacity: 1,
136+
},
137+
},
117138
});

packages/perseus/src/strings.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1259,8 +1259,8 @@ export const strings = {
12591259
imageExploreButton: "Explore image",
12601260
imageAlternativeTitle: "Explore image and description",
12611261
imageDescriptionLabel: "Description",
1262-
imageZoomAriaLabel: "Zoom image.",
1263-
imageResetZoomAriaLabel: "Reset zoom.",
1262+
imageZoomAriaLabel: "Make image bigger.",
1263+
imageResetZoomAriaLabel: "Close image.",
12641264
gifPlayButtonLabel: "Play Animation",
12651265
gifPauseButtonLabel: "Pause Animation",
12661266
} satisfies {
@@ -1635,8 +1635,8 @@ export const mockStrings: PerseusStrings = {
16351635
imageExploreButton: "Explore image",
16361636
imageAlternativeTitle: "Explore image and description",
16371637
imageDescriptionLabel: "Description",
1638-
imageZoomAriaLabel: "Zoom image.",
1639-
imageResetZoomAriaLabel: "Reset zoom.",
1638+
imageZoomAriaLabel: "Make image bigger.",
1639+
imageResetZoomAriaLabel: "Close image.",
16401640
gifPlayButtonLabel: "Play Animation",
16411641
gifPauseButtonLabel: "Pause Animation",
16421642
};

packages/perseus/src/widgets/categorizer/__snapshots__/categorizer.test.ts.snap

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ exports[`categorizer widget should snapshot on mobile: first mobile render 1`] =
358358
</div>
359359
</span>
360360
<button
361-
aria-label="Zoom image."
361+
aria-label="Make image bigger."
362362
class="button_vr44p2-o_O-reset_152ygtm-o_O-link_13xlah4-o_O-inlineStyles_1l8d9fz"
363363
type="button"
364364
/>
@@ -410,7 +410,7 @@ exports[`categorizer widget should snapshot on mobile: first mobile render 1`] =
410410
</div>
411411
</span>
412412
<button
413-
aria-label="Zoom image."
413+
aria-label="Make image bigger."
414414
class="button_vr44p2-o_O-reset_152ygtm-o_O-link_13xlah4-o_O-inlineStyles_1l8d9fz"
415415
type="button"
416416
/>
@@ -871,7 +871,7 @@ exports[`categorizer widget should snapshot: first render 1`] = `
871871
</div>
872872
</span>
873873
<button
874-
aria-label="Zoom image."
874+
aria-label="Make image bigger."
875875
class="button_vr44p2-o_O-reset_152ygtm-o_O-link_13xlah4-o_O-inlineStyles_1l8d9fz"
876876
type="button"
877877
/>
@@ -923,7 +923,7 @@ exports[`categorizer widget should snapshot: first render 1`] = `
923923
</div>
924924
</span>
925925
<button
926-
aria-label="Zoom image."
926+
aria-label="Make image bigger."
927927
class="button_vr44p2-o_O-reset_152ygtm-o_O-link_13xlah4-o_O-inlineStyles_1l8d9fz"
928928
type="button"
929929
/>

0 commit comments

Comments
 (0)