diff --git a/src/lib/viewers/controls/_styles.scss b/src/lib/viewers/controls/_styles.scss new file mode 100644 index 000000000..e3655bd19 --- /dev/null +++ b/src/lib/viewers/controls/_styles.scss @@ -0,0 +1,41 @@ +@import '~box-ui-elements/es/styles/variables'; + +@mixin bp-ControlButton($height: 48px, $width: 48px) { + display: flex; + align-items: center; + justify-content: center; + width: $width; + height: $height; + color: $white; + background: transparent; + border: 1px solid transparent; + outline: 0; + cursor: pointer; + opacity: .7; + transition: opacity 150ms; + user-select: none; + touch-action: manipulation; + zoom: 1; + + &:focus, + &:hover { + opacity: 1; + } + + &:focus { + box-shadow: inset 0 0 0 1px fade-out($white, .5), 0 1px 2px fade-out($black, .9); + } + + &:disabled { + cursor: default; + opacity: .2; + pointer-events: none; + } +} + +@mixin bp-ControlGroup { + display: flex; + align-items: center; + margin-right: 4px; + margin-left: 4px; +} diff --git a/src/lib/viewers/controls/fullscreen/FullscreenToggle.scss b/src/lib/viewers/controls/fullscreen/FullscreenToggle.scss new file mode 100644 index 000000000..8e4ac071b --- /dev/null +++ b/src/lib/viewers/controls/fullscreen/FullscreenToggle.scss @@ -0,0 +1,5 @@ +@import 'src/lib/viewers/controls/styles'; + +.bp-FullscreenToggle { + @include bp-ControlButton; +} diff --git a/src/lib/viewers/controls/fullscreen/FullscreenToggle.tsx b/src/lib/viewers/controls/fullscreen/FullscreenToggle.tsx new file mode 100644 index 000000000..b6109093c --- /dev/null +++ b/src/lib/viewers/controls/fullscreen/FullscreenToggle.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import fullscreen from '../../../Fullscreen'; +import IconFullscreenIn24 from '../icons/IconFullscreenIn24'; +import IconFullscreenOut24 from '../icons/IconFullscreenOut24'; +import './FullscreenToggle.scss'; + +export type Props = { + onFullscreenToggle: (isFullscreen: boolean) => void; +}; + +export default function FullscreenToggle({ onFullscreenToggle }: Props): JSX.Element { + const [isFullscreen, setFullscreen] = React.useState(false); + const Icon = isFullscreen ? IconFullscreenOut24 : IconFullscreenIn24; + const title = isFullscreen ? __('exit_fullscreen') : __('enter_fullscreen'); + + const handleClick = (): void => { + onFullscreenToggle(!isFullscreen); + }; + + React.useEffect(() => { + const handleFullscreenEnter = (): void => setFullscreen(true); + const handleFullscreenExit = (): void => setFullscreen(false); + + fullscreen.addListener('enter', handleFullscreenEnter); + fullscreen.addListener('exit', handleFullscreenExit); + + return (): void => { + fullscreen.removeListener('enter', handleFullscreenEnter); + fullscreen.removeListener('exit', handleFullscreenExit); + }; + }, []); + + return ( + + ); +} diff --git a/src/lib/viewers/controls/fullscreen/__tests__/FullscreenToggle-test.tsx b/src/lib/viewers/controls/fullscreen/__tests__/FullscreenToggle-test.tsx new file mode 100644 index 000000000..4b79f4942 --- /dev/null +++ b/src/lib/viewers/controls/fullscreen/__tests__/FullscreenToggle-test.tsx @@ -0,0 +1,45 @@ +import React from 'react'; +import { shallow, ShallowWrapper } from 'enzyme'; +import fullscreen from '../../../../Fullscreen'; +import FullscreenToggle from '../FullscreenToggle'; +import IconFullscreenIn24 from '../../icons/IconFullscreenIn24'; +import IconFullscreenOut24 from '../../icons/IconFullscreenOut24'; + +describe('FullscreenToggle', () => { + const getWrapper = (props = {}): ShallowWrapper => + shallow(); + + beforeEach(() => { + jest.spyOn(React, 'useEffect').mockImplementation(fn => fn()); + }); + + describe('event handlers', () => { + test('should respond to fullscreen events', () => { + const wrapper = getWrapper(); + + fullscreen.enter(); + expect(wrapper.exists(IconFullscreenOut24)).toBe(true); + expect(wrapper.prop('title')).toBe(__('exit_fullscreen')); + + fullscreen.exit(); + expect(wrapper.exists(IconFullscreenIn24)).toBe(true); + expect(wrapper.prop('title')).toBe(__('enter_fullscreen')); + }); + + test('should invoke onFullscreenToggle prop on click', () => { + const onToggle = jest.fn(); + const wrapper = getWrapper({ onFullscreenToggle: onToggle }); + + wrapper.simulate('click'); + expect(onToggle).toBeCalledWith(true); + }); + }); + + describe('render', () => { + test('should return a valid wrapper', () => { + const wrapper = getWrapper(); + + expect(wrapper.hasClass('bp-FullscreenToggle')).toBe(true); + }); + }); +}); diff --git a/src/lib/viewers/controls/fullscreen/index.ts b/src/lib/viewers/controls/fullscreen/index.ts new file mode 100644 index 000000000..0fb941d6b --- /dev/null +++ b/src/lib/viewers/controls/fullscreen/index.ts @@ -0,0 +1,2 @@ +export * from './FullscreenToggle'; +export { default } from './FullscreenToggle'; diff --git a/src/lib/viewers/controls/icons/IconZoomIn10.tsx b/src/lib/viewers/controls/icons/IconZoomIn10.tsx index 11e87f92a..e27e0657f 100644 --- a/src/lib/viewers/controls/icons/IconZoomIn10.tsx +++ b/src/lib/viewers/controls/icons/IconZoomIn10.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -function IconZoomIn(props: React.SVGProps): JSX.Element { +function IconZoomIn10(props: React.SVGProps): JSX.Element { return ( ): JSX.Element { ); } -export default IconZoomIn; +export default IconZoomIn10; diff --git a/src/lib/viewers/controls/icons/IconZoomOut10.tsx b/src/lib/viewers/controls/icons/IconZoomOut10.tsx index 87a6a9a96..888e57f19 100644 --- a/src/lib/viewers/controls/icons/IconZoomOut10.tsx +++ b/src/lib/viewers/controls/icons/IconZoomOut10.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -function IconZoomOut(props: React.SVGProps): JSX.Element { +function IconZoomOut10(props: React.SVGProps): JSX.Element { return ( @@ -8,4 +8,4 @@ function IconZoomOut(props: React.SVGProps): JSX.Element { ); } -export default IconZoomOut; +export default IconZoomOut10; diff --git a/src/lib/viewers/controls/zoom/ZoomControls.scss b/src/lib/viewers/controls/zoom/ZoomControls.scss new file mode 100644 index 000000000..1c0589d1c --- /dev/null +++ b/src/lib/viewers/controls/zoom/ZoomControls.scss @@ -0,0 +1,19 @@ +@import '../styles'; + +.bp-ZoomControls { + @include bp-ControlGroup; +} + +.bp-ZoomControls-button { + @include bp-ControlButton($width: 32px); +} + +.bp-ZoomControls-current { + display: flex; + align-items: center; + justify-content: center; + min-width: 48px; + color: #fff; + font-size: 14px; + user-select: none; +} diff --git a/src/lib/viewers/controls/zoom/ZoomControls.tsx b/src/lib/viewers/controls/zoom/ZoomControls.tsx new file mode 100644 index 000000000..6cb61a7ba --- /dev/null +++ b/src/lib/viewers/controls/zoom/ZoomControls.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import isFinite from 'lodash/isFinite'; +import IconZoomIn10 from '../icons/IconZoomIn10'; +import IconZoomOut10 from '../icons/IconZoomOut10'; +import './ZoomControls.scss'; + +export type Props = { + maxScale?: number; + minScale?: number; + onZoomIn: () => void; + onZoomOut: () => void; + scale?: number; +}; + +export const MAX_SCALE = 100; +export const MIN_SCALE = 0.1; + +export default function ZoomControls({ + maxScale = MAX_SCALE, + minScale = MIN_SCALE, + onZoomIn, + onZoomOut, + scale = 1, +}: Props): JSX.Element { + const currentZoom = Math.round(scale * 100); + const maxScaleValue = isFinite(maxScale) ? Math.min(maxScale, MAX_SCALE) : MAX_SCALE; + const minScaleValue = isFinite(minScale) ? Math.max(minScale, MIN_SCALE) : MIN_SCALE; + + return ( +
+ +
{`${currentZoom}%`}
+ +
+ ); +} diff --git a/src/lib/viewers/controls/zoom/__tests__/ZoomControls-test.tsx b/src/lib/viewers/controls/zoom/__tests__/ZoomControls-test.tsx new file mode 100644 index 000000000..2d611dba4 --- /dev/null +++ b/src/lib/viewers/controls/zoom/__tests__/ZoomControls-test.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import noop from 'lodash/noop'; +import { shallow, ShallowWrapper } from 'enzyme'; +import ZoomControls from '../ZoomControls'; + +describe('ZoomControls', () => { + const getWrapper = (props = {}): ShallowWrapper => + shallow(); + const getZoom = (wrapper: ShallowWrapper): ShallowWrapper => wrapper.find('[data-testid="current-zoom"]'); + const getZoomIn = (wrapper: ShallowWrapper): ShallowWrapper => wrapper.find('[data-testid="bp-ZoomControls-in"]'); + const getZoomOut = (wrapper: ShallowWrapper): ShallowWrapper => wrapper.find('[data-testid="bp-ZoomControls-out"]'); + + describe('event handlers', () => { + test('should handle zoom in click', () => { + const onZoomIn = jest.fn(); + const wrapper = getWrapper({ onZoomIn }); + + getZoomIn(wrapper).simulate('click'); + + expect(onZoomIn).toBeCalled(); + }); + + test('should handle zoom out click', () => { + const onZoomOut = jest.fn(); + const wrapper = getWrapper({ onZoomOut }); + + getZoomOut(wrapper).simulate('click'); + + expect(onZoomOut).toBeCalled(); + }); + }); + + describe('render', () => { + test.each` + minScale | scale | disabled + ${null} | ${1} | ${false} + ${0.5} | ${1} | ${false} + ${0.5} | ${0.5} | ${true} + ${-50} | ${0.1} | ${true} + ${-50} | ${0.2} | ${false} + `('should set disabled for zoom out to $disabled for $scale / $minScale', ({ disabled, minScale, scale }) => { + const wrapper = getWrapper({ minScale, scale }); + + expect(getZoomOut(wrapper).prop('disabled')).toBe(disabled); + }); + + test.each` + maxScale | scale | disabled + ${null} | ${1} | ${false} + ${10} | ${1} | ${false} + ${50} | ${10} | ${false} + ${50} | ${50} | ${true} + ${500} | ${100} | ${true} + ${500} | ${99} | ${false} + `('should set disabled for zoom in to $disabled for $scale / $maxScale', ({ disabled, maxScale, scale }) => { + const wrapper = getWrapper({ maxScale, scale }); + + expect(getZoomIn(wrapper).prop('disabled')).toBe(disabled); + }); + + test.each` + scale | zoom + ${1} | ${'100%'} + ${1.49} | ${'149%'} + ${1.499} | ${'150%'} + ${10} | ${'1000%'} + ${100} | ${'10000%'} + `('should format $scale to $zoom properly', ({ scale, zoom }) => { + const wrapper = getWrapper({ scale }); + + expect(getZoom(wrapper).text()).toEqual(zoom); + }); + + test('should return a valid wrapper', () => { + const wrapper = getWrapper(); + + expect(getZoom(wrapper)).toBeDefined(); + expect(getZoomIn(wrapper)).toBeDefined(); + expect(getZoomOut(wrapper)).toBeDefined(); + expect(wrapper.hasClass('bp-ZoomControls')).toBe(true); + }); + }); +}); diff --git a/src/lib/viewers/controls/zoom/index.ts b/src/lib/viewers/controls/zoom/index.ts new file mode 100644 index 000000000..564091561 --- /dev/null +++ b/src/lib/viewers/controls/zoom/index.ts @@ -0,0 +1,2 @@ +export * from './ZoomControls'; +export { default } from './ZoomControls';