diff --git a/README.md b/README.md index 69dd39fcc..b1cf5be16 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # react-viewer -[![NPM version][npm-image]][npm-url] [![](https://travis-ci.org/infeng/react-viewer.svg?branch=master)](https://travis-ci.org/infeng/react-viewer) [![codecov](https://codecov.io/gh/infeng/react-viewer/branch/master/graph/badge.svg)](https://codecov.io/gh/infeng/react-viewer) +[![NPM version][npm-image]][npm-url] [![CI](https://github.com/infeng/react-viewer/actions/workflows/ci.yml/badge.svg)](https://github.com/infeng/react-viewer/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/infeng/react-viewer/branch/master/graph/badge.svg)](https://codecov.io/gh/infeng/react-viewer) > react image viewer. ## Introduction @@ -37,38 +37,40 @@ function App() { } ``` -## Server Side (NextJS) +## Next.js (client-only) -``` -import React, { FC } from 'react' -import dynamic from 'next/dynamic' +`react-viewer` uses browser DOM APIs and must be loaded with SSR disabled. In the +Next.js App Router, put the dynamic import in a Client Component: + +```tsx +'use client'; + +import dynamic from 'next/dynamic'; const ReactViewer = dynamic( () => import('react-viewer').then((mod) => mod.default), - { ssr: false } -) + { ssr: false }, +); -export const Viewer: FC = () => { +export default function Viewer() { return ( {}} images={[{src: ''}]} /> - ) + ); } - -export default Viewer ``` -## Server Side rest.. -I'm sorry, ssr is not currently supported in `3.x`, it will be fixed in `4.0`. +The Pages Router uses the same dynamic import but does not require the +`'use client'` directive. Direct server rendering is not supported in `3.x`. ## Props | props | type | default | description | required | |--------------|--------------|---------|-----------------------------|----------| -| visible | string | false | Viewer visible | true | +| visible | boolean | false | Viewer visible | true | | onClose | function | - | Specify a function that will be called when Visible close | true | | images | [ImageDecorator](#imagedecorator)[] | [] | image source array | true | | activeIndex | number | 0 | active image index | false | @@ -86,11 +88,11 @@ I'm sorry, ssr is not currently supported in `3.x`, it will be fixed in `4.0`. | noToolbar | boolean | false | to not render the toolbar | false | | noImgDetails | boolean | false | to not render image detail (WxH) | false | | noFooter | boolean | false | to not render the entire footer | false | -| changeable | boolean | true | wheather to show change button | false | -| customToolbar | (defaultToolbarConfigs: [ToolbarConfig](#toolbarconfig)[]) => ToolbarConfig[] | - | customer toolbar | false | +| changeable | boolean | true | whether to show change buttons | false | +| customToolbar | (defaultToolbarConfigs: [ToolbarConfig](#toolbarconfig)[]) => ToolbarConfig[] | - | customize the toolbar | false | | zoomSpeed | number | 0.05 | zoom speed | false | | defaultSize | [ViewerImageSize](#viewerimagesize) | - | default image size | false | -| defaultImg | [viewerdefaultimg](#viewerimagesize) | - | if load img failed, show default img | false | +| defaultImg | [ViewerDefaultImg](#viewerdefaultimg) | - | fallback shown when an image fails to load | false | | disableKeyboardSupport | boolean | false | disable keyboard support | false | | noResetZoomAfterChange | boolean | false | preserve zoom after image change | false | | noLimitInitializationSize | boolean | false | no limit image initialization size | false | @@ -112,7 +114,7 @@ I'm sorry, ssr is not currently supported in `3.x`, it will be fixed in `4.0`. |-------------|--------------|---------|-----------------------------|----------| | src | string | - | image source | true | | alt | string | - | image description | false | -| downloadUrl | string | - | image downlaod url | false | +| downloadUrl | string | - | image download URL | false | | defaultSize | [ViewerImageSize](#viewerimagesize) | - | image size | false | ### ViewerImageSize @@ -126,7 +128,7 @@ I'm sorry, ssr is not currently supported in `3.x`, it will be fixed in `4.0`. | props | type | default | description | required | |-------------|--------------|---------|-----------------------------|----------| -| src | number | - | image source | true | +| src | string | - | image source | true | | width | number | - | image width | false | | height | number | - | image height | false | @@ -138,6 +140,21 @@ I'm sorry, ssr is not currently supported in `3.x`, it will be fixed in `4.0`. | render | React.ReactNode | - | tool render | false | | onClick | function | - | callback function when action is clicked | false | +To keep only selected built-in controls, filter the configurations passed to +`customToolbar`. The `key` values are stable and avoid depending on numeric +`actionType` values: + +```tsx + setVisible(false)} + images={images} + customToolbar={(toolbars) => + toolbars.filter(({ key }) => key === 'zoomIn' || key === 'zoomOut') + } +/> +``` + ## Keyboard support - `Esc`: Close viewer. diff --git a/demo/index.tsx b/demo/index.tsx index 12e950993..ee51ee260 100644 --- a/demo/index.tsx +++ b/demo/index.tsx @@ -177,8 +177,8 @@ class App extends React.Component> { - - + + diff --git a/src/ViewerCore.tsx b/src/ViewerCore.tsx index 7efb85369..642aae5b7 100644 --- a/src/ViewerCore.tsx +++ b/src/ViewerCore.tsx @@ -671,6 +671,16 @@ export default (props: ViewerProps) => { onClick={() => { onClose(); }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ' || event.keyCode === 13 || event.keyCode === 32) { + event.preventDefault(); + event.stopPropagation(); + onClose(); + } + }} + role="button" + tabIndex={0} + aria-label="Close viewer" style={{ zIndex: zIndex + 10 }} > diff --git a/src/ViewerNav.tsx b/src/ViewerNav.tsx index e485929aa..b35842ab9 100644 --- a/src/ViewerNav.tsx +++ b/src/ViewerNav.tsx @@ -31,6 +31,16 @@ export default function ViewerNav(props: ViewerNavProps) { key={index} className={index === activeIndex ? 'active' : ''} onClick={() => { handleChangeImg(index); }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ' || event.keyCode === 13 || event.keyCode === 32) { + event.preventDefault(); + event.stopPropagation(); + handleChangeImg(index); + } + }} + role="button" + tabIndex={0} + aria-label={item.alt ? `View ${item.alt}` : `View image ${index + 1}`} > {item.alt} , diff --git a/src/ViewerToolbar.tsx b/src/ViewerToolbar.tsx index d4715f7cd..df35460f8 100644 --- a/src/ViewerToolbar.tsx +++ b/src/ViewerToolbar.tsx @@ -65,6 +65,19 @@ export const defaultToolbars: ToolbarConfig[] = [ }, ]; +const toolbarLabels = { + zoomIn: 'Zoom in', + zoomOut: 'Zoom out', + prev: 'Previous image', + reset: 'Reset image', + next: 'Next image', + rotateLeft: 'Rotate left', + rotateRight: 'Rotate right', + scaleX: 'Flip horizontally', + scaleY: 'Flip vertically', + download: 'Download image', +}; + function deleteToolbarFromKey(toolbars: ToolbarConfig[], keys: string[]) { const targetToolbar = toolbars.filter(item => keys.indexOf(item.key) < 0); @@ -91,6 +104,16 @@ export default function ViewerToolbar(props: ViewerToolbarProps) { key={config.key} className={`${props.prefixCls}-btn`} onClick={() => {handleAction(config); }} + onKeyDown={(event) => { + if (event.key === 'Enter' || event.key === ' ' || event.keyCode === 13 || event.keyCode === 32) { + event.preventDefault(); + event.stopPropagation(); + handleAction(config); + } + }} + role="button" + tabIndex={0} + aria-label={toolbarLabels[config.key] || config.key} data-key={config.key} > {content} diff --git a/src/__tests__/viewer.test.tsx b/src/__tests__/viewer.test.tsx index 0784a78e0..9c7a3ae47 100644 --- a/src/__tests__/viewer.test.tsx +++ b/src/__tests__/viewer.test.tsx @@ -239,6 +239,36 @@ describe('Viewer', () => { expect($$('.react-viewer')[0].style.display).toBe('none'); }); + it('exposes viewer controls to keyboard users', () => { + viewerHelper.new(); + viewerHelper.open(); + + const nextButton = $$('li[data-key=next]')[0] as HTMLElement; + expect(nextButton.getAttribute('role')).toBe('button'); + expect(nextButton.getAttribute('tabindex')).toBe('0'); + expect(nextButton.getAttribute('aria-label')).toBe('Next image'); + triggerKeyboard(nextButton, 'keydown', 13); + viewerHelper.skipAnimation(); + expect($$('.react-viewer-attribute')[0].innerHTML).toContain('mountain'); + + const firstThumbnail = $$('.react-viewer-list li')[0] as HTMLElement; + expect(firstThumbnail.getAttribute('role')).toBe('button'); + expect(firstThumbnail.getAttribute('tabindex')).toBe('0'); + expect(firstThumbnail.getAttribute('aria-label')).toBe('View lake'); + triggerKeyboard(firstThumbnail, 'keydown', 32); + viewerHelper.skipAnimation(); + expect($$('.react-viewer-attribute')[0].innerHTML).toContain('lake'); + + const closeButton = $$('.react-viewer-close')[0] as HTMLElement; + expect(closeButton.getAttribute('role')).toBe('button'); + expect(closeButton.getAttribute('tabindex')).toBe('0'); + expect(closeButton.getAttribute('aria-label')).toBe('Close viewer'); + triggerKeyboard(closeButton, 'keydown', 13); + viewerHelper.skipAnimation(); + wrapper.find('.react-viewer').simulate('transitionend'); + expect($$('.react-viewer')[0].style.display).toBe('none'); + }); + it('does not render or reserve space for the footer', () => { viewerHelper.new({ noFooter: true }); viewerHelper.open(); @@ -727,6 +757,7 @@ describe('Viewer', () => { // reset triggerKeyboard(document, 'keydown', 39, true); + viewerHelper.skipAnimation(); triggerKeyboard(document, 'keydown', 49, true); viewerHelper.skipAnimation(); imgNode = $$('img.react-viewer-image')[0]; diff --git a/src/style/index.less b/src/style/index.less index 3764182d9..0bc053f45 100644 --- a/src/style/index.less +++ b/src/style/index.less @@ -60,6 +60,11 @@ background-color: @btn-hover-background-color; } + &-btn:focus { + outline: 2px solid white; + outline-offset: -2px; + } + &-close { position: fixed; top: 0px; @@ -186,6 +191,11 @@ opacity: 1; } + &-list > li:focus { + outline: 2px solid white; + outline-offset: -2px; + } + @transitionDuration: .3s; @transitionFunc: ease-out;