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/package.json b/package.json index 0efb43a..5feb3be 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "dependencies": { "@rc-component/motion": "^1.0.0", "@rc-component/portal": "^2.1.2", - "@rc-component/util": "^1.3.0", + "@rc-component/util": "^1.10.0", "clsx": "^2.1.1" }, "devDependencies": { diff --git a/src/Image.tsx b/src/Image.tsx index 4e37d86..22c37a0 100644 --- a/src/Image.tsx +++ b/src/Image.tsx @@ -44,7 +44,7 @@ export interface PreviewConfig extends Omit, 'placeholder' | 'onClick'> { + extends Omit, 'placeholder' | 'onClick' | 'onKeyDown'> { // Misc prefixCls?: string; previewPrefixCls?: string; @@ -73,6 +73,7 @@ export interface ImageProps // Events onClick?: (e: React.MouseEvent) => void; onError?: (e: React.SyntheticEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; } interface CompoundedComponent

extends React.FC

{ @@ -108,6 +109,7 @@ const ImageInternal: CompoundedComponent = props => { // Events onClick, onError, + onKeyDown, ...otherProps } = props; @@ -203,6 +205,33 @@ const ImageInternal: CompoundedComponent = props => { onClick?.(e); }; + // ======================= Keyboard Preview ===================== + const onPreviewKeyDown: React.KeyboardEventHandler = event => { + onKeyDown?.(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 +241,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 ? otherProps['aria-label'] ?? alt : otherProps['aria-label']} + onKeyDown={onPreviewKeyDown} 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..338f782 100644 --- a/src/Preview/PrevNext.tsx +++ b/src/Preview/PrevNext.tsx @@ -21,24 +21,30 @@ export default function PrevNext(props: PrevNextProps) { const switchCls = `${prefixCls}-switch`; + const prevDisabled = current === 0; + const nextDisabled = current === count - 1; + return ( <> -
onActive(-1)} + disabled={prevDisabled} > {prev ?? left} -
-
+
+ ); } diff --git a/src/Preview/index.tsx b/src/Preview/index.tsx index 05594fc..2030504 100644 --- a/src/Preview/index.tsx +++ b/src/Preview/index.tsx @@ -1,6 +1,7 @@ import CSSMotion from '@rc-component/motion'; import Portal, { type PortalProps } from '@rc-component/portal'; import { useEvent } from '@rc-component/util'; +import { useLockFocus } from '@rc-component/util/lib/Dom/focus'; import useLayoutEffect from '@rc-component/util/lib/hooks/useLayoutEffect'; import KeyCode from '@rc-component/util/lib/KeyCode'; import { clsx } from 'clsx'; @@ -195,6 +196,7 @@ const Preview: React.FC = props => { } = props; const imgRef = useRef(); + const wrapperRef = useRef(null); const groupContext = useContext(PreviewGroupContext); const showLeftOrRightSwitches = groupContext && count > 1; const showOperationsProgress = groupContext && count >= 1; @@ -382,6 +384,9 @@ const Preview: React.FC = props => { } }; + // =========================== Focus ============================ + useLockFocus(open && portalRender, () => wrapperRef.current); + // ========================== Render ========================== const bodyStyle: React.CSSProperties = { ...styles.body, @@ -418,10 +423,15 @@ const Preview: React.FC = props => { return (
{/* Mask */}
{ const MockPreview = (props: any) => { @@ -1144,4 +1144,96 @@ describe('Preview', () => { 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 wrapper should be focusable after portal renders', () => { + const rectSpy = jest.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + x: 0, + y: 0, + width: 100, + height: 100, + top: 0, + right: 100, + bottom: 100, + left: 0, + toJSON: () => undefined, + } as DOMRect); + + render(focus portal); + + act(() => { + jest.runAllTimers(); + }); + + const preview = document.querySelector('.rc-image-preview') as HTMLElement; + + expect(preview.contains(document.activeElement)).toBeTruthy(); + + rectSpy.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(