Skip to content

Commit 8c398fc

Browse files
feat: improve Image preview accessibility
1 parent 2825512 commit 8c398fc

5 files changed

Lines changed: 84 additions & 6 deletions

File tree

src/Image.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,18 @@ const ImageInternal: CompoundedComponent<ImageProps> = props => {
203203
onClick?.(e);
204204
};
205205

206+
// ======================= Keyboard Preview =====================
207+
const onPreviewKeyDown: React.KeyboardEventHandler<HTMLDivElement> = event => {
208+
if (!canPreview) {
209+
return;
210+
}
211+
212+
if (event.key === 'Enter' || event.key === ' ') {
213+
event.preventDefault();
214+
onPreview(event as any);
215+
}
216+
};
217+
206218
// =========================== Render ===========================
207219
return (
208220
<>
@@ -212,6 +224,10 @@ const ImageInternal: CompoundedComponent<ImageProps> = props => {
212224
[`${prefixCls}-error`]: status === 'error',
213225
})}
214226
onClick={canPreview ? onPreview : onClick}
227+
role={canPreview ? 'button' : otherProps.role}
228+
tabIndex={canPreview && otherProps.tabIndex == null ? 0 : otherProps.tabIndex}
229+
aria-label={canPreview ? (alt || 'Preview image') : otherProps['aria-label']}
230+
onKeyDown={onPreviewKeyDown}
215231
style={{
216232
width,
217233
height,

src/Preview/Footer.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,22 +23,28 @@ export default function PrevNext(props: PrevNextProps) {
2323

2424
return (
2525
<>
26-
<div
26+
<button
27+
type="button"
2728
className={clsx(switchCls, `${switchCls}-prev`, {
2829
[`${switchCls}-disabled`]: current === 0,
2930
})}
3031
onClick={() => onActive(-1)}
32+
disabled={current === 0}
33+
aria-label="Previous image"
3134
>
3235
{prev ?? left}
33-
</div>
34-
<div
36+
</button>
37+
<button
38+
type="button"
3539
className={clsx(switchCls, `${switchCls}-next`, {
3640
[`${switchCls}-disabled`]: current === count - 1,
3741
})}
3842
onClick={() => onActive(1)}
43+
disabled={current === count - 1}
44+
aria-label="Next image"
3945
>
4046
{next ?? right}
41-
</div>
47+
</button>
4248
</>
4349
);
4450
}

src/Preview/index.tsx

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ const Preview: React.FC<PreviewProps> = props => {
195195
} = props;
196196

197197
const imgRef = useRef<HTMLImageElement>();
198+
const wrapperRef = useRef<HTMLDivElement>(null);
199+
const lastActiveRef = useRef<HTMLElement | null>(null);
198200
const groupContext = useContext(PreviewGroupContext);
199201
const showLeftOrRightSwitches = groupContext && count > 1;
200202
const showOperationsProgress = groupContext && count >= 1;
@@ -239,6 +241,20 @@ const Preview: React.FC<PreviewProps> = props => {
239241
}
240242
}, [open]);
241243

244+
// =========================== Focus ============================
245+
useEffect(() => {
246+
if (open) {
247+
lastActiveRef.current = (document.activeElement as HTMLElement) || null;
248+
249+
if (wrapperRef.current) {
250+
wrapperRef.current.focus();
251+
}
252+
} else if (!open && lastActiveRef.current) {
253+
lastActiveRef.current.focus();
254+
lastActiveRef.current = null;
255+
}
256+
}, [open]);
257+
242258
// ========================== Image ===========================
243259
const onDoubleClick = (event: React.MouseEvent<HTMLImageElement, MouseEvent>) => {
244260
if (open) {
@@ -418,10 +434,15 @@ const Preview: React.FC<PreviewProps> = props => {
418434

419435
return (
420436
<div
437+
ref={wrapperRef}
421438
className={clsx(prefixCls, rootClassName, classNames.root, motionClassName, {
422439
[`${prefixCls}-moving`]: isMoving,
423440
})}
424441
style={mergedStyle}
442+
role="dialog"
443+
aria-modal="true"
444+
aria-label={alt || 'Image preview'}
445+
tabIndex={-1}
425446
>
426447
{/* Mask */}
427448
<div

tests/preview.test.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1139,4 +1139,36 @@ describe('Preview', () => {
11391139
expect(baseElement.querySelector('.rc-image-preview')).toHaveClass(customClassnames.popup.root);
11401140
expect(baseElement.querySelector('.rc-image-preview')).toHaveStyle(customStyles.popup.root);
11411141
});
1142+
1143+
it('Image wrapper should be keyboard focusable when preview enabled', () => {
1144+
const { container } = render(<Image src="src" alt="keyboard test" />);
1145+
1146+
const wrapper = container.querySelector('.rc-image') as HTMLElement;
1147+
expect(wrapper).toHaveAttribute('role', 'button');
1148+
expect(wrapper).toHaveAttribute('tabindex', '0');
1149+
});
1150+
1151+
it('Pressing Enter on image wrapper should open preview', () => {
1152+
const { container } = render(<Image src="src" alt="keyboard open" />);
1153+
1154+
const wrapper = container.querySelector('.rc-image') as HTMLElement;
1155+
wrapper.focus();
1156+
fireEvent.keyDown(wrapper, { key: 'Enter' });
1157+
1158+
act(() => {
1159+
jest.runAllTimers();
1160+
});
1161+
1162+
expect(document.querySelector('.rc-image-preview')).toBeTruthy();
1163+
});
1164+
1165+
it('Preview dialog should have role dialog and receive focus', () => {
1166+
render(<Image src="src" alt="dialog a11y" preview={{ open: true }} />);
1167+
1168+
const preview = document.querySelector('.rc-image-preview') as HTMLElement;
1169+
expect(preview).toHaveAttribute('role', 'dialog');
1170+
expect(preview).toHaveAttribute('aria-modal', 'true');
1171+
expect(preview).toHaveAttribute('aria-label', 'dialog a11y');
1172+
expect(document.activeElement).toBe(preview);
1173+
});
11421174
});

0 commit comments

Comments
 (0)