From 40c9dc14744f5d7c09dd0d69f6995bdaa737e423 Mon Sep 17 00:00:00 2001 From: Clayton Date: Fri, 13 Mar 2026 02:01:58 -0500 Subject: [PATCH 1/6] feat: improve Image preview accessibility --- src/Image.tsx | 16 ++++++++++++++++ src/Preview/Footer.tsx | 7 +++++-- src/Preview/PrevNext.tsx | 14 ++++++++++---- src/Preview/index.tsx | 21 +++++++++++++++++++++ tests/preview.test.tsx | 32 ++++++++++++++++++++++++++++++++ 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/src/Image.tsx b/src/Image.tsx index 4e37d86..fcadb5f 100644 --- a/src/Image.tsx +++ b/src/Image.tsx @@ -203,6 +203,18 @@ const ImageInternal: CompoundedComponent = props => { onClick?.(e); }; + // ======================= Keyboard Preview ===================== + const onPreviewKeyDown: React.KeyboardEventHandler = event => { + if (!canPreview) { + return; + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onPreview(event as any); + } + }; + // =========================== Render =========================== return ( <> @@ -212,6 +224,10 @@ const ImageInternal: CompoundedComponent = props => { [`${prefixCls}-error`]: status === 'error', })} onClick={canPreview ? onPreview : onClick} + role={canPreview ? 'button' : otherProps.role} + tabIndex={canPreview && otherProps.tabIndex == null ? 0 : otherProps.tabIndex} + aria-label={canPreview ? (alt || 'Preview image') : otherProps['aria-label']} + onKeyDown={onPreviewKeyDown} style={{ width, height, diff --git a/src/Preview/Footer.tsx b/src/Preview/Footer.tsx index d03cfe3..0623db8 100644 --- a/src/Preview/Footer.tsx +++ b/src/Preview/Footer.tsx @@ -95,15 +95,18 @@ export default function Footer(props: FooterProps) { const renderOperation = ({ type, disabled, onClick, icon }: RenderOperationParams) => { return ( -
{icon} -
+ ); }; diff --git a/src/Preview/PrevNext.tsx b/src/Preview/PrevNext.tsx index 049e721..9b82a51 100644 --- a/src/Preview/PrevNext.tsx +++ b/src/Preview/PrevNext.tsx @@ -23,22 +23,28 @@ export default function PrevNext(props: PrevNextProps) { return ( <> -
onActive(-1)} + disabled={current === 0} + aria-label="Previous image" > {prev ?? left} -
-
+
+ ); } diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index 05594fc..a4d48fd 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -195,6 +195,8 @@ const Preview: React.FC = props => { } = props; const imgRef = useRef(); + const wrapperRef = useRef(null); + const lastActiveRef = useRef(null); const groupContext = useContext(PreviewGroupContext); const showLeftOrRightSwitches = groupContext && count > 1; const showOperationsProgress = groupContext && count >= 1; @@ -239,6 +241,20 @@ const Preview: React.FC = props => { } }, [open]); + // =========================== Focus ============================ + useEffect(() => { + if (open) { + lastActiveRef.current = (document.activeElement as HTMLElement) || null; + + if (wrapperRef.current) { + wrapperRef.current.focus(); + } + } else if (!open && lastActiveRef.current) { + lastActiveRef.current.focus(); + lastActiveRef.current = null; + } + }, [open]); + // ========================== Image =========================== const onDoubleClick = (event: React.MouseEvent) => { if (open) { @@ -418,10 +434,15 @@ const Preview: React.FC = props => { return (
{/* Mask */}
{ expect(baseElement.querySelector('.rc-image-preview')).toHaveClass(customClassnames.popup.root); expect(baseElement.querySelector('.rc-image-preview')).toHaveStyle(customStyles.popup.root); }); + + it('Image wrapper should be keyboard focusable when preview enabled', () => { + const { container } = render(keyboard test); + + const wrapper = container.querySelector('.rc-image') as HTMLElement; + expect(wrapper).toHaveAttribute('role', 'button'); + expect(wrapper).toHaveAttribute('tabindex', '0'); + }); + + it('Pressing Enter on image wrapper should open preview', () => { + const { container } = render(keyboard open); + + const wrapper = container.querySelector('.rc-image') as HTMLElement; + wrapper.focus(); + fireEvent.keyDown(wrapper, { key: 'Enter' }); + + act(() => { + jest.runAllTimers(); + }); + + expect(document.querySelector('.rc-image-preview')).toBeTruthy(); + }); + + it('Preview dialog should have role dialog and receive focus', () => { + render(dialog a11y); + + const preview = document.querySelector('.rc-image-preview') as HTMLElement; + expect(preview).toHaveAttribute('role', 'dialog'); + expect(preview).toHaveAttribute('aria-modal', 'true'); + expect(preview).toHaveAttribute('aria-label', 'dialog a11y'); + expect(document.activeElement).toBe(preview); + }); }); From 9315b8b49ca0855fc32e68107e4c74c826a75f9d Mon Sep 17 00:00:00 2001 From: Clayton Date: Fri, 13 Mar 2026 02:39:51 -0500 Subject: [PATCH 2/6] fix: test --- tests/__snapshots__/basic.test.tsx.snap | 3 +++ tests/preview.test.tsx | 1 - 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/__snapshots__/basic.test.tsx.snap b/tests/__snapshots__/basic.test.tsx.snap index 4c1c335..a5c3a12 100644 --- a/tests/__snapshots__/basic.test.tsx.snap +++ b/tests/__snapshots__/basic.test.tsx.snap @@ -2,8 +2,11 @@ exports[`Basic snapshot 1`] = `
{ expect(preview).toHaveAttribute('role', 'dialog'); expect(preview).toHaveAttribute('aria-modal', 'true'); expect(preview).toHaveAttribute('aria-label', 'dialog a11y'); - expect(document.activeElement).toBe(preview); }); }); From c9ddd2926fc06af6d5ab141653de9da80d92716c Mon Sep 17 00:00:00 2001 From: Clayton Date: Fri, 13 Mar 2026 06:09:05 -0500 Subject: [PATCH 3/6] fix: test --- src/Preview/Footer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Preview/Footer.tsx b/src/Preview/Footer.tsx index 0623db8..d487672 100644 --- a/src/Preview/Footer.tsx +++ b/src/Preview/Footer.tsx @@ -20,7 +20,7 @@ interface RenderOperationParams { icon: React.ReactNode; type: OperationType; disabled?: boolean; - onClick: (e: React.MouseEvent) => void; + onClick: React.MouseEventHandler; } export interface FooterProps extends Actions { From 46c52592c63a6bef4ea6fbf4cee69122a0073505 Mon Sep 17 00:00:00 2001 From: Clayton Date: Tue, 17 Mar 2026 01:47:13 -0500 Subject: [PATCH 4/6] fix: update --- assets/preview.less | 7 +++++++ src/Image.tsx | 24 +++++++++++++++++++++--- tests/preview.test.tsx | 14 ++++++++++++++ 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/assets/preview.less b/assets/preview.less index e60e906..0882b33 100644 --- a/assets/preview.less +++ b/assets/preview.less @@ -58,9 +58,12 @@ height: 40px; color: #fff; background: rgba(0, 0, 0, 0.3); + border: 0; + padding: 0; border-radius: 9999px; transform: translateY(-50%); cursor: pointer; + font: inherit; &-disabled { cursor: default; @@ -104,6 +107,10 @@ &-action { color: #fff; cursor: pointer; + border: 0; + padding: 0; + background: transparent; + font: inherit; &-disabled { cursor: default; diff --git a/src/Image.tsx b/src/Image.tsx index fcadb5f..80f6598 100644 --- a/src/Image.tsx +++ b/src/Image.tsx @@ -211,7 +211,20 @@ const ImageInternal: CompoundedComponent = props => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); - onPreview(event as any); + + const rect = (event.target as HTMLDivElement).getBoundingClientRect(); + const left = rect.x + rect.width / 2; + const top = rect.y + rect.height / 2; + + if (groupContext) { + groupContext.onPreview(imageId, src, left, top); + } else { + setMousePosition({ + x: left, + y: top, + }); + triggerPreviewOpen(true); + } } }; @@ -226,8 +239,13 @@ const ImageInternal: CompoundedComponent = props => { onClick={canPreview ? onPreview : onClick} role={canPreview ? 'button' : otherProps.role} tabIndex={canPreview && otherProps.tabIndex == null ? 0 : otherProps.tabIndex} - aria-label={canPreview ? (alt || 'Preview image') : otherProps['aria-label']} - onKeyDown={onPreviewKeyDown} + aria-label={ + canPreview ? (otherProps['aria-label'] ?? alt ?? 'Preview image') : otherProps['aria-label'] + } + onKeyDown={event => { + onPreviewKeyDown(event); + (otherProps as any).onKeyDown?.(event); + }} style={{ width, height, diff --git a/tests/preview.test.tsx b/tests/preview.test.tsx index e7ca5a3..161496d 100644 --- a/tests/preview.test.tsx +++ b/tests/preview.test.tsx @@ -1167,6 +1167,20 @@ describe('Preview', () => { expect(document.querySelector('.rc-image-preview')).toBeTruthy(); }); + it('Pressing Space on image wrapper should open preview', () => { + const { container } = render(keyboard open space); + + const wrapper = container.querySelector('.rc-image') as HTMLElement; + wrapper.focus(); + fireEvent.keyDown(wrapper, { key: ' ' }); + + act(() => { + jest.runAllTimers(); + }); + + expect(document.querySelector('.rc-image-preview')).toBeTruthy(); + }); + it('Preview dialog should have role dialog and receive focus', () => { render(dialog a11y); From 875e3cf5fdd6b41aefb4a2ad5489a7e860d4de0c Mon Sep 17 00:00:00 2001 From: Clayton Date: Wed, 18 Mar 2026 03:05:39 -0500 Subject: [PATCH 5/6] fix: test --- tests/preview.test.tsx | 21 +++++++++++++++++++++ tests/previewGroup.test.tsx | 19 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/tests/preview.test.tsx b/tests/preview.test.tsx index 161496d..61128b8 100644 --- a/tests/preview.test.tsx +++ b/tests/preview.test.tsx @@ -1189,4 +1189,25 @@ describe('Preview', () => { expect(preview).toHaveAttribute('aria-modal', 'true'); expect(preview).toHaveAttribute('aria-label', 'dialog a11y'); }); + + it('Preview open should render focusable wrapper', () => { + render(focus test); + + const preview = document.querySelector('.rc-image-preview') as HTMLElement; + expect(preview).toHaveAttribute('tabindex', '-1'); + }); + + it('Pressing Enter should not open preview when preview is disabled', () => { + const { container } = render(disabled preview); + + const wrapper = container.querySelector('.rc-image') as HTMLElement; + wrapper.focus(); + fireEvent.keyDown(wrapper, { key: 'Enter' }); + + act(() => { + jest.runAllTimers(); + }); + + expect(document.querySelector('.rc-image-preview')).toBeFalsy(); + }); }); diff --git a/tests/previewGroup.test.tsx b/tests/previewGroup.test.tsx index 0c847ed..178950d 100644 --- a/tests/previewGroup.test.tsx +++ b/tests/previewGroup.test.tsx @@ -108,6 +108,25 @@ describe('PreviewGroup', () => { expect(document.querySelector('.rc-image-preview')).toBeFalsy(); }); + it('Keyboard Enter should open preview from group image', () => { + const { container } = render( + + first + second + , + ); + + const first = container.querySelector('.rc-image') as HTMLElement; + first.focus(); + fireEvent.keyDown(first, { key: 'Enter' }); + + act(() => { + jest.runAllTimers(); + }); + + expect(document.querySelector('.rc-image-preview')).toBeTruthy(); + }); + it('Preview with Custom Preview Property', () => { const { container } = render( Date: Wed, 18 Mar 2026 08:19:43 -0500 Subject: [PATCH 6/6] fix: codecov --- src/Preview/index.tsx | 30 ++++++++++++++++-------------- tests/preview.test.tsx | 13 +++++++++++++ 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index a4d48fd..52d4812 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -241,20 +241,6 @@ const Preview: React.FC = props => { } }, [open]); - // =========================== Focus ============================ - useEffect(() => { - if (open) { - lastActiveRef.current = (document.activeElement as HTMLElement) || null; - - if (wrapperRef.current) { - wrapperRef.current.focus(); - } - } else if (!open && lastActiveRef.current) { - lastActiveRef.current.focus(); - lastActiveRef.current = null; - } - }, [open]); - // ========================== Image =========================== const onDoubleClick = (event: React.MouseEvent) => { if (open) { @@ -398,6 +384,22 @@ const Preview: React.FC = props => { } }; + // =========================== Focus ============================ + useEffect(() => { + if (open) { + lastActiveRef.current = (document.activeElement as HTMLElement) || null; + + // When `open` is initially true, the portal content is rendered in a later effect. + // Depend on `portalRender` so we can focus once the wrapper is actually mounted. + if (wrapperRef.current && portalRender) { + wrapperRef.current.focus(); + } + } else if (!open && lastActiveRef.current) { + lastActiveRef.current.focus(); + lastActiveRef.current = null; + } + }, [open, portalRender]); + // ========================== Render ========================== const bodyStyle: React.CSSProperties = { ...styles.body, diff --git a/tests/preview.test.tsx b/tests/preview.test.tsx index 61128b8..0b8652b 100644 --- a/tests/preview.test.tsx +++ b/tests/preview.test.tsx @@ -1190,6 +1190,19 @@ describe('Preview', () => { expect(preview).toHaveAttribute('aria-label', 'dialog a11y'); }); + it('Preview should focus wrapper after portal renders', () => { + const focusSpy = jest.spyOn(HTMLElement.prototype, 'focus'); + + render(focus portal); + + act(() => { + jest.runAllTimers(); + }); + + expect(focusSpy).toHaveBeenCalled(); + focusSpy.mockRestore(); + }); + it('Preview open should render focusable wrapper', () => { render(focus test);