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 4e37d86..80f6598 100644 --- a/src/Image.tsx +++ b/src/Image.tsx @@ -203,6 +203,31 @@ const ImageInternal: CompoundedComponent = props => { onClick?.(e); }; + // ======================= Keyboard Preview ===================== + const onPreviewKeyDown: React.KeyboardEventHandler = event => { + if (!canPreview) { + return; + } + + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + + 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); + } + } + }; + // =========================== Render =========================== return ( <> @@ -212,6 +237,15 @@ 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 ? (otherProps['aria-label'] ?? alt ?? 'Preview image') : otherProps['aria-label'] + } + onKeyDown={event => { + onPreviewKeyDown(event); + (otherProps as any).onKeyDown?.(event); + }} style={{ width, height, diff --git a/src/Preview/Footer.tsx b/src/Preview/Footer.tsx index d03cfe3..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 { @@ -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..52d4812 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; @@ -382,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, @@ -418,10 +436,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('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); + + 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'); + }); + + 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); + + 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(