Skip to content

Commit c000c2d

Browse files
feat: improve Image preview accessibility (#496)
* feat: improve Image preview accessibility * fix: test * fix: test * fix: update * fix: test * fix: codecov * fix: update * fix: update * fix: test * chore: checkpoint local changes * test: fix preview focus assertion in jsdom * chore: remove unused preview focus ref --------- Co-authored-by: 二货机器人 <smith3816@gmail.com>
1 parent 56868cc commit c000c2d

File tree

9 files changed

+183
-11
lines changed

9 files changed

+183
-11
lines changed

assets/preview.less

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,12 @@
5858
height: 40px;
5959
color: #fff;
6060
background: rgba(0, 0, 0, 0.3);
61+
border: 0;
62+
padding: 0;
6163
border-radius: 9999px;
6264
transform: translateY(-50%);
6365
cursor: pointer;
66+
font: inherit;
6467

6568
&-disabled {
6669
cursor: default;
@@ -104,6 +107,10 @@
104107
&-action {
105108
color: #fff;
106109
cursor: pointer;
110+
border: 0;
111+
padding: 0;
112+
background: transparent;
113+
font: inherit;
107114

108115
&-disabled {
109116
cursor: default;

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@
4242
"dependencies": {
4343
"@rc-component/motion": "^1.0.0",
4444
"@rc-component/portal": "^2.1.2",
45-
"@rc-component/util": "^1.3.0",
45+
"@rc-component/util": "^1.10.0",
4646
"clsx": "^2.1.1"
4747
},
4848
"devDependencies": {

src/Image.tsx

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export interface PreviewConfig extends Omit<InternalPreviewConfig, 'countRender'
4444
export type SemanticName = 'root' | 'image' | 'cover';
4545

4646
export interface ImageProps
47-
extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'placeholder' | 'onClick'> {
47+
extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'placeholder' | 'onClick' | 'onKeyDown'> {
4848
// Misc
4949
prefixCls?: string;
5050
previewPrefixCls?: string;
@@ -73,6 +73,7 @@ export interface ImageProps
7373
// Events
7474
onClick?: (e: React.MouseEvent<HTMLDivElement>) => void;
7575
onError?: (e: React.SyntheticEvent<HTMLImageElement, Event>) => void;
76+
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
7677
}
7778

7879
interface CompoundedComponent<P> extends React.FC<P> {
@@ -108,6 +109,7 @@ const ImageInternal: CompoundedComponent<ImageProps> = props => {
108109
// Events
109110
onClick,
110111
onError,
112+
onKeyDown,
111113
...otherProps
112114
} = props;
113115

@@ -203,6 +205,33 @@ const ImageInternal: CompoundedComponent<ImageProps> = props => {
203205
onClick?.(e);
204206
};
205207

208+
// ======================= Keyboard Preview =====================
209+
const onPreviewKeyDown: React.KeyboardEventHandler<HTMLDivElement> = event => {
210+
onKeyDown?.(event);
211+
212+
if (!canPreview) {
213+
return;
214+
}
215+
216+
if (event.key === 'Enter' || event.key === ' ') {
217+
event.preventDefault();
218+
219+
const rect = (event.target as HTMLDivElement).getBoundingClientRect();
220+
const left = rect.x + rect.width / 2;
221+
const top = rect.y + rect.height / 2;
222+
223+
if (groupContext) {
224+
groupContext.onPreview(imageId, src, left, top);
225+
} else {
226+
setMousePosition({
227+
x: left,
228+
y: top,
229+
});
230+
triggerPreviewOpen(true);
231+
}
232+
}
233+
};
234+
206235
// =========================== Render ===========================
207236
return (
208237
<>
@@ -212,6 +241,10 @@ const ImageInternal: CompoundedComponent<ImageProps> = props => {
212241
[`${prefixCls}-error`]: status === 'error',
213242
})}
214243
onClick={canPreview ? onPreview : onClick}
244+
role={canPreview ? 'button' : otherProps.role}
245+
tabIndex={canPreview && otherProps.tabIndex == null ? 0 : otherProps.tabIndex}
246+
aria-label={canPreview ? otherProps['aria-label'] ?? alt : otherProps['aria-label']}
247+
onKeyDown={onPreviewKeyDown}
215248
style={{
216249
width,
217250
height,

src/Preview/Footer.tsx

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ interface RenderOperationParams {
2020
icon: React.ReactNode;
2121
type: OperationType;
2222
disabled?: boolean;
23-
onClick: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
23+
onClick: React.MouseEventHandler<HTMLButtonElement>;
2424
}
2525

2626
export interface FooterProps extends Actions {
@@ -95,15 +95,18 @@ export default function Footer(props: FooterProps) {
9595

9696
const renderOperation = ({ type, disabled, onClick, icon }: RenderOperationParams) => {
9797
return (
98-
<div
98+
<button
99+
type="button"
99100
key={type}
100101
className={clsx(actionCls, `${actionCls}-${type}`, {
101102
[`${actionCls}-disabled`]: !!disabled,
102103
})}
103104
onClick={onClick}
105+
disabled={!!disabled}
106+
aria-label={type}
104107
>
105108
{icon}
106-
</div>
109+
</button>
107110
);
108111
};
109112

src/Preview/PrevNext.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,24 +21,30 @@ export default function PrevNext(props: PrevNextProps) {
2121

2222
const switchCls = `${prefixCls}-switch`;
2323

24+
const prevDisabled = current === 0;
25+
const nextDisabled = current === count - 1;
26+
2427
return (
2528
<>
26-
<div
29+
<button
2730
className={clsx(switchCls, `${switchCls}-prev`, {
28-
[`${switchCls}-disabled`]: current === 0,
31+
[`${switchCls}-disabled`]: prevDisabled,
2932
})}
3033
onClick={() => onActive(-1)}
34+
disabled={prevDisabled}
3135
>
3236
{prev ?? left}
33-
</div>
34-
<div
37+
</button>
38+
<button
39+
type="button"
3540
className={clsx(switchCls, `${switchCls}-next`, {
36-
[`${switchCls}-disabled`]: current === count - 1,
41+
[`${switchCls}-disabled`]: nextDisabled,
3742
})}
3843
onClick={() => onActive(1)}
44+
disabled={nextDisabled}
3945
>
4046
{next ?? right}
41-
</div>
47+
</button>
4248
</>
4349
);
4450
}

src/Preview/index.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import CSSMotion from '@rc-component/motion';
22
import Portal, { type PortalProps } from '@rc-component/portal';
33
import { useEvent } from '@rc-component/util';
4+
import { useLockFocus } from '@rc-component/util/lib/Dom/focus';
45
import useLayoutEffect from '@rc-component/util/lib/hooks/useLayoutEffect';
56
import KeyCode from '@rc-component/util/lib/KeyCode';
67
import { clsx } from 'clsx';
@@ -195,6 +196,7 @@ const Preview: React.FC<PreviewProps> = props => {
195196
} = props;
196197

197198
const imgRef = useRef<HTMLImageElement>();
199+
const wrapperRef = useRef<HTMLDivElement>(null);
198200
const groupContext = useContext(PreviewGroupContext);
199201
const showLeftOrRightSwitches = groupContext && count > 1;
200202
const showOperationsProgress = groupContext && count >= 1;
@@ -382,6 +384,9 @@ const Preview: React.FC<PreviewProps> = props => {
382384
}
383385
};
384386

387+
// =========================== Focus ============================
388+
useLockFocus(open && portalRender, () => wrapperRef.current);
389+
385390
// ========================== Render ==========================
386391
const bodyStyle: React.CSSProperties = {
387392
...styles.body,
@@ -418,11 +423,16 @@ const Preview: React.FC<PreviewProps> = props => {
418423

419424
return (
420425
<div
426+
ref={wrapperRef}
421427
className={clsx(prefixCls, rootClassName, classNames.root, motionClassName, {
422428
[`${prefixCls}-movable`]: movable,
423429
[`${prefixCls}-moving`]: isMoving,
424430
})}
425431
style={mergedStyle}
432+
role="dialog"
433+
aria-modal="true"
434+
aria-label={alt}
435+
tabIndex={-1}
426436
>
427437
{/* Mask */}
428438
<div

tests/__snapshots__/basic.test.tsx.snap

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
exports[`Basic snapshot 1`] = `
44
<div
55
class="rc-image"
6+
role="button"
67
style="width: 200px;"
8+
tabindex="0"
79
>
810
<img
911
class="rc-image-img"

tests/preview.test.tsx

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,4 +1164,96 @@ describe('Preview', () => {
11641164
expect(baseElement.querySelector('.rc-image-preview')).toHaveClass(customClassnames.popup.root);
11651165
expect(baseElement.querySelector('.rc-image-preview')).toHaveStyle(customStyles.popup.root);
11661166
});
1167+
1168+
it('Image wrapper should be keyboard focusable when preview enabled', () => {
1169+
const { container } = render(<Image src="src" alt="keyboard test" />);
1170+
1171+
const wrapper = container.querySelector('.rc-image') as HTMLElement;
1172+
expect(wrapper).toHaveAttribute('role', 'button');
1173+
expect(wrapper).toHaveAttribute('tabindex', '0');
1174+
});
1175+
1176+
it('Pressing Enter on image wrapper should open preview', () => {
1177+
const { container } = render(<Image src="src" alt="keyboard open" />);
1178+
1179+
const wrapper = container.querySelector('.rc-image') as HTMLElement;
1180+
wrapper.focus();
1181+
fireEvent.keyDown(wrapper, { key: 'Enter' });
1182+
1183+
act(() => {
1184+
jest.runAllTimers();
1185+
});
1186+
1187+
expect(document.querySelector('.rc-image-preview')).toBeTruthy();
1188+
});
1189+
1190+
it('Pressing Space on image wrapper should open preview', () => {
1191+
const { container } = render(<Image src="src" alt="keyboard open space" />);
1192+
1193+
const wrapper = container.querySelector('.rc-image') as HTMLElement;
1194+
wrapper.focus();
1195+
fireEvent.keyDown(wrapper, { key: ' ' });
1196+
1197+
act(() => {
1198+
jest.runAllTimers();
1199+
});
1200+
1201+
expect(document.querySelector('.rc-image-preview')).toBeTruthy();
1202+
});
1203+
1204+
it('Preview dialog should have role dialog and receive focus', () => {
1205+
render(<Image src="src" alt="dialog a11y" preview={{ open: true }} />);
1206+
1207+
const preview = document.querySelector('.rc-image-preview') as HTMLElement;
1208+
expect(preview).toHaveAttribute('role', 'dialog');
1209+
expect(preview).toHaveAttribute('aria-modal', 'true');
1210+
expect(preview).toHaveAttribute('aria-label', 'dialog a11y');
1211+
});
1212+
1213+
it('Preview wrapper should be focusable after portal renders', () => {
1214+
const rectSpy = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
1215+
x: 0,
1216+
y: 0,
1217+
width: 100,
1218+
height: 100,
1219+
top: 0,
1220+
right: 100,
1221+
bottom: 100,
1222+
left: 0,
1223+
toJSON: () => undefined,
1224+
} as DOMRect);
1225+
1226+
render(<Image src="src" alt="focus portal" preview={{ open: true }} />);
1227+
1228+
act(() => {
1229+
jest.runAllTimers();
1230+
});
1231+
1232+
const preview = document.querySelector('.rc-image-preview') as HTMLElement;
1233+
1234+
expect(preview.contains(document.activeElement)).toBeTruthy();
1235+
1236+
rectSpy.mockRestore();
1237+
});
1238+
1239+
it('Preview open should render focusable wrapper', () => {
1240+
render(<Image src="src" alt="focus test" preview={{ open: true }} />);
1241+
1242+
const preview = document.querySelector('.rc-image-preview') as HTMLElement;
1243+
expect(preview).toHaveAttribute('tabindex', '-1');
1244+
});
1245+
1246+
it('Pressing Enter should not open preview when preview is disabled', () => {
1247+
const { container } = render(<Image src="src" alt="disabled preview" preview={false} />);
1248+
1249+
const wrapper = container.querySelector('.rc-image') as HTMLElement;
1250+
wrapper.focus();
1251+
fireEvent.keyDown(wrapper, { key: 'Enter' });
1252+
1253+
act(() => {
1254+
jest.runAllTimers();
1255+
});
1256+
1257+
expect(document.querySelector('.rc-image-preview')).toBeFalsy();
1258+
});
11671259
});

tests/previewGroup.test.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,25 @@ describe('PreviewGroup', () => {
108108
expect(document.querySelector('.rc-image-preview')).toBeFalsy();
109109
});
110110

111+
it('Keyboard Enter should open preview from group image', () => {
112+
const { container } = render(
113+
<Image.PreviewGroup>
114+
<Image src="src1" alt="first" />
115+
<Image src="src2" alt="second" />
116+
</Image.PreviewGroup>,
117+
);
118+
119+
const first = container.querySelector('.rc-image') as HTMLElement;
120+
first.focus();
121+
fireEvent.keyDown(first, { key: 'Enter' });
122+
123+
act(() => {
124+
jest.runAllTimers();
125+
});
126+
127+
expect(document.querySelector('.rc-image-preview')).toBeTruthy();
128+
});
129+
111130
it('Preview with Custom Preview Property', () => {
112131
const { container } = render(
113132
<Image.PreviewGroup

0 commit comments

Comments
 (0)