From d00879d5075b66079cecdf6245d6a891144e8195 Mon Sep 17 00:00:00 2001 From: Jared Stoffan Date: Tue, 3 Nov 2020 14:17:50 -0800 Subject: [PATCH] feat(controls): Add react versions of core control components (#1282) --- package.json | 1 + src/lib/types/global.ts | 1 + .../controls/controls-bar/ControlsBar.scss | 8 ++ .../controls/controls-bar/ControlsBar.tsx | 14 ++ .../__tests__/ControlsBar-test.tsx | 15 +++ .../viewers/controls/controls-bar/index.ts | 1 + .../controls-layer/ControlsLayer.scss | 9 ++ .../controls/controls-layer/ControlsLayer.tsx | 85 ++++++++++++ .../__tests__/ControlsLayer-test.tsx | 115 +++++++++++++++++ .../viewers/controls/controls-layer/index.ts | 2 + .../controls/controls-root/ControlsRoot.scss | 13 ++ .../controls/controls-root/ControlsRoot.tsx | 71 ++++++++++ .../__tests__/ControlsRoot-test.tsx | 122 ++++++++++++++++++ .../viewers/controls/controls-root/index.ts | 1 + yarn.lock | 15 +++ 15 files changed, 473 insertions(+) create mode 100644 src/lib/types/global.ts create mode 100644 src/lib/viewers/controls/controls-bar/ControlsBar.scss create mode 100644 src/lib/viewers/controls/controls-bar/ControlsBar.tsx create mode 100644 src/lib/viewers/controls/controls-bar/__tests__/ControlsBar-test.tsx create mode 100644 src/lib/viewers/controls/controls-bar/index.ts create mode 100644 src/lib/viewers/controls/controls-layer/ControlsLayer.scss create mode 100644 src/lib/viewers/controls/controls-layer/ControlsLayer.tsx create mode 100644 src/lib/viewers/controls/controls-layer/__tests__/ControlsLayer-test.tsx create mode 100644 src/lib/viewers/controls/controls-layer/index.ts create mode 100644 src/lib/viewers/controls/controls-root/ControlsRoot.scss create mode 100644 src/lib/viewers/controls/controls-root/ControlsRoot.tsx create mode 100644 src/lib/viewers/controls/controls-root/__tests__/ControlsRoot-test.tsx create mode 100644 src/lib/viewers/controls/controls-root/index.ts diff --git a/package.json b/package.json index 7258c7289..c4b3aed63 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@commitlint/config-conventional": "^8.2.0", "@commitlint/travis-cli": "^8.2.0", "@testing-library/jest-dom": "^5.11.4", + "@types/enzyme": "^3.10.8", "@types/lodash": "^4.14.149", "@types/react": "^16.9.0", "@types/react-dom": "^16.9.0", diff --git a/src/lib/types/global.ts b/src/lib/types/global.ts new file mode 100644 index 000000000..71a7cf4a0 --- /dev/null +++ b/src/lib/types/global.ts @@ -0,0 +1 @@ +declare const __: Function; diff --git a/src/lib/viewers/controls/controls-bar/ControlsBar.scss b/src/lib/viewers/controls/controls-bar/ControlsBar.scss new file mode 100644 index 000000000..2f3e89154 --- /dev/null +++ b/src/lib/viewers/controls/controls-bar/ControlsBar.scss @@ -0,0 +1,8 @@ +@import '~box-ui-elements/es/styles/variables'; + +.bp-ControlsBar { + display: flex; + align-items: center; + background: fade-out($black, .2); + border-radius: 3px; +} diff --git a/src/lib/viewers/controls/controls-bar/ControlsBar.tsx b/src/lib/viewers/controls/controls-bar/ControlsBar.tsx new file mode 100644 index 000000000..58c261d15 --- /dev/null +++ b/src/lib/viewers/controls/controls-bar/ControlsBar.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import './ControlsBar.scss'; + +export type Props = { + children: React.ReactNode; +}; + +export default function ControlsBar({ children, ...rest }: Props): JSX.Element { + return ( +
+ {children} +
+ ); +} diff --git a/src/lib/viewers/controls/controls-bar/__tests__/ControlsBar-test.tsx b/src/lib/viewers/controls/controls-bar/__tests__/ControlsBar-test.tsx new file mode 100644 index 000000000..4b0b14ea5 --- /dev/null +++ b/src/lib/viewers/controls/controls-bar/__tests__/ControlsBar-test.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import ControlsBar from '../ControlsBar'; + +describe('ControlsBar', () => { + describe('render', () => { + test('should return a valid wrapper', () => { + const children =
Hello
; + const wrapper = shallow({children}); + + expect(wrapper.contains(children)).toBe(true); + expect(wrapper.hasClass('bp-ControlsBar')).toBe(true); + }); + }); +}); diff --git a/src/lib/viewers/controls/controls-bar/index.ts b/src/lib/viewers/controls/controls-bar/index.ts new file mode 100644 index 000000000..a06ea5e90 --- /dev/null +++ b/src/lib/viewers/controls/controls-bar/index.ts @@ -0,0 +1 @@ +export { default } from './ControlsBar'; diff --git a/src/lib/viewers/controls/controls-layer/ControlsLayer.scss b/src/lib/viewers/controls/controls-layer/ControlsLayer.scss new file mode 100644 index 000000000..3d34a543b --- /dev/null +++ b/src/lib/viewers/controls/controls-layer/ControlsLayer.scss @@ -0,0 +1,9 @@ +.bp-ControlsLayer { + display: flex; + opacity: 0; + transition: opacity .5s; + + &.bp-is-visible { + opacity: 1; + } +} diff --git a/src/lib/viewers/controls/controls-layer/ControlsLayer.tsx b/src/lib/viewers/controls/controls-layer/ControlsLayer.tsx new file mode 100644 index 000000000..c88c8dd21 --- /dev/null +++ b/src/lib/viewers/controls/controls-layer/ControlsLayer.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import noop from 'lodash/noop'; +import './ControlsLayer.scss'; + +export type Helpers = { + hide: () => void; + reset: () => void; + show: () => void; +}; + +export type Props = { + children: React.ReactNode; + onMount?: (helpers: Helpers) => void; +}; + +export const HIDE_DELAY_MS = 2000; +export const SHOW_CLASSNAME = 'bp-is-visible'; + +export default function ControlsLayer({ children, onMount = noop }: Props): JSX.Element { + const [isShown, setIsShown] = React.useState(false); + const hasFocusRef = React.useRef(false); + const hasCursorRef = React.useRef(false); + const hideTimeoutRef = React.useRef(); + + // Visibility helpers + const helpersRef = React.useRef({ + hide() { + window.clearTimeout(hideTimeoutRef.current); + + hideTimeoutRef.current = window.setTimeout(() => { + if (hasCursorRef.current || hasFocusRef.current) { + return; + } + + setIsShown(false); + }, HIDE_DELAY_MS); + }, + reset() { + hasCursorRef.current = false; + hasFocusRef.current = false; + }, + show() { + window.clearTimeout(hideTimeoutRef.current); + setIsShown(true); + }, + }); + + // Event handlers + const handleFocusIn = (): void => { + hasFocusRef.current = true; + helpersRef.current.show(); + }; + + const handleFocusOut = (): void => { + hasFocusRef.current = false; + helpersRef.current.hide(); + }; + + const handleMouseEnter = (): void => { + hasCursorRef.current = true; + helpersRef.current.show(); + }; + + const handleMouseLeave = (): void => { + hasCursorRef.current = false; + helpersRef.current.hide(); + }; + + // Expose helpers to parent + React.useEffect(() => { + onMount(helpersRef.current); + }, [onMount]); + + return ( +
+ {children} +
+ ); +} diff --git a/src/lib/viewers/controls/controls-layer/__tests__/ControlsLayer-test.tsx b/src/lib/viewers/controls/controls-layer/__tests__/ControlsLayer-test.tsx new file mode 100644 index 000000000..db22d5720 --- /dev/null +++ b/src/lib/viewers/controls/controls-layer/__tests__/ControlsLayer-test.tsx @@ -0,0 +1,115 @@ +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mount, ReactWrapper } from 'enzyme'; +import ControlsLayer, { HIDE_DELAY_MS, SHOW_CLASSNAME } from '../ControlsLayer'; + +describe('ControlsLayer', () => { + const children =
Controls
; + const getElement = (wrapper: ReactWrapper): ReactWrapper => wrapper.childAt(0); + const getWrapper = (props = {}): ReactWrapper => mount({children}); + + beforeEach(() => { + jest.useFakeTimers(); + }); + + describe('event handlers', () => { + test.each(['focus', 'mouseenter'])('should show the controls %s', eventProp => { + const wrapper = getWrapper(); + + act(() => { + getElement(wrapper).simulate(eventProp); + }); + wrapper.update(); + + expect(getElement(wrapper).hasClass(SHOW_CLASSNAME)).toBe(true); + }); + + test.each` + showTrigger | hideTrigger + ${'focus'} | ${'blur'} + ${'mouseenter'} | ${'mouseleave'} + `('should show $showTrigger and hide $hideTrigger', ({ hideTrigger, showTrigger }) => { + const wrapper = getWrapper(); + + act(() => { + getElement(wrapper).simulate(showTrigger); + }); + wrapper.update(); + + expect(getElement(wrapper).hasClass(SHOW_CLASSNAME)).toBe(true); + + act(() => { + getElement(wrapper).simulate(hideTrigger); + jest.advanceTimersByTime(HIDE_DELAY_MS); + }); + wrapper.update(); + + expect(getElement(wrapper).hasClass(SHOW_CLASSNAME)).toBe(false); + }); + + test('should always show the controls if they have focus', () => { + const wrapper = getWrapper(); + + act(() => { + getElement(wrapper).simulate('focus'); + getElement(wrapper).simulate('mouseenter'); + }); + wrapper.update(); + + expect(getElement(wrapper).hasClass(SHOW_CLASSNAME)).toBe(true); + + act(() => { + getElement(wrapper).simulate('mouseleave'); + jest.advanceTimersByTime(HIDE_DELAY_MS); + }); + wrapper.update(); + + expect(getElement(wrapper).hasClass(SHOW_CLASSNAME)).toBe(true); + }); + + test('should always show the controls if they have the mouse cursor', () => { + const wrapper = getWrapper(); + + act(() => { + getElement(wrapper).simulate('focus'); + getElement(wrapper).simulate('mouseenter'); + }); + wrapper.update(); + + expect(getElement(wrapper).hasClass(SHOW_CLASSNAME)).toBe(true); + + act(() => { + getElement(wrapper).simulate('blur'); + jest.advanceTimersByTime(HIDE_DELAY_MS); + }); + wrapper.update(); + + expect(getElement(wrapper).hasClass(SHOW_CLASSNAME)).toBe(true); + }); + }); + + describe('render', () => { + test('should invoke the onMount callback once with the visibility helpers', () => { + const onMount = jest.fn(); + const wrapper = getWrapper({ onMount }); + + wrapper.update(); + wrapper.update(); + wrapper.update(); + + expect(onMount).toBeCalledTimes(1); + expect(onMount).toBeCalledWith({ + hide: expect.any(Function), + reset: expect.any(Function), + show: expect.any(Function), + }); + }); + + test('should return a valid wrapper', () => { + const wrapper = getWrapper(); + + expect(wrapper.contains(children)).toBe(true); + expect(wrapper.childAt(0).hasClass('bp-ControlsLayer')).toBe(true); + }); + }); +}); diff --git a/src/lib/viewers/controls/controls-layer/index.ts b/src/lib/viewers/controls/controls-layer/index.ts new file mode 100644 index 000000000..269a31a04 --- /dev/null +++ b/src/lib/viewers/controls/controls-layer/index.ts @@ -0,0 +1,2 @@ +export * from './ControlsLayer'; +export { default } from './ControlsLayer'; diff --git a/src/lib/viewers/controls/controls-root/ControlsRoot.scss b/src/lib/viewers/controls/controls-root/ControlsRoot.scss new file mode 100644 index 000000000..372e1022c --- /dev/null +++ b/src/lib/viewers/controls/controls-root/ControlsRoot.scss @@ -0,0 +1,13 @@ +@import '~box-ui-elements/es/styles/variables'; + +.bp-ControlsRoot { + position: absolute; + bottom: 25px; + left: 50%; + transform: translate3d(-50%, 0, 0); + backface-visibility: hidden; + + &.bp-is-hidden { + display: none; + } +} diff --git a/src/lib/viewers/controls/controls-root/ControlsRoot.tsx b/src/lib/viewers/controls/controls-root/ControlsRoot.tsx new file mode 100644 index 000000000..ecf40eae0 --- /dev/null +++ b/src/lib/viewers/controls/controls-root/ControlsRoot.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import noop from 'lodash/noop'; +import throttle from 'lodash/throttle'; +import ControlsLayer, { Helpers } from '../controls-layer'; +import './ControlsRoot.scss'; + +export type Options = { + containerEl: HTMLElement; +}; + +export default class ControlsRoot { + containerEl: HTMLElement; + + controlsEl: HTMLElement; + + controlsLayer: Helpers = { + hide: noop, + reset: noop, + show: noop, + }; + + constructor({ containerEl }: Options) { + this.controlsEl = document.createElement('div'); + this.controlsEl.setAttribute('class', 'bp-ControlsRoot'); + this.controlsEl.setAttribute('data-testid', 'bp-controls'); + this.controlsEl.setAttribute('data-resin-component', 'toolbar'); + + this.containerEl = containerEl; + this.containerEl.addEventListener('mousemove', this.handleMouseMove); + this.containerEl.addEventListener('touchstart', this.handleTouchStart); + this.containerEl.appendChild(this.controlsEl); + } + + handleMount = (helpers: Helpers): void => { + this.controlsLayer = helpers; + }; + + handleMouseMove = throttle((): void => { + this.controlsLayer.show(); + this.controlsLayer.hide(); // Hide after delay unless movement is continuous + }, 100); + + handleTouchStart = throttle((): void => { + this.controlsLayer.reset(); // Ignore focus/hover state for touch events + this.controlsLayer.show(); + this.controlsLayer.hide(); // Hide after delay unless movement is continuous + }, 100); + + destroy(): void { + ReactDOM.unmountComponentAtNode(this.controlsEl); + + if (this.containerEl) { + this.containerEl.removeEventListener('mousemove', this.handleMouseMove); + this.containerEl.removeEventListener('touchstart', this.handleMouseMove); + this.containerEl.removeChild(this.controlsEl); + } + } + + disable(): void { + this.controlsEl.classList.add('bp-is-hidden'); + } + + enable(): void { + this.controlsEl.classList.remove('bp-is-hidden'); + } + + render(controls: JSX.Element): void { + ReactDOM.render({controls}, this.controlsEl); + } +} diff --git a/src/lib/viewers/controls/controls-root/__tests__/ControlsRoot-test.tsx b/src/lib/viewers/controls/controls-root/__tests__/ControlsRoot-test.tsx new file mode 100644 index 000000000..423d2d7a8 --- /dev/null +++ b/src/lib/viewers/controls/controls-root/__tests__/ControlsRoot-test.tsx @@ -0,0 +1,122 @@ +import React from 'react'; +import ControlsRoot from '../ControlsRoot'; + +describe('ControlsRoot', () => { + const getInstance = (options = {}): ControlsRoot => + new ControlsRoot({ containerEl: document.createElement('div'), ...options }); + + describe('constructor', () => { + test('should inject a controls root element into the container', () => { + const instance = getInstance(); + + expect(instance.containerEl.firstChild).toMatchInlineSnapshot(` +
+ `); + }); + + test('should attach event handlers to the container element', () => { + const containerEl = document.createElement('div'); + const addListener = jest.spyOn(containerEl, 'addEventListener'); + const instance = getInstance({ containerEl }); + + expect(addListener).toBeCalledWith('mousemove', instance.handleMouseMove); + expect(addListener).toBeCalledWith('touchstart', instance.handleTouchStart); + }); + }); + + describe('destroy', () => { + test('should remove event handlers from the container element', () => { + const containerEl = document.createElement('div'); + const removeListener = jest.spyOn(containerEl, 'removeEventListener'); + const instance = getInstance({ containerEl }); + + instance.destroy(); + + expect(removeListener).toBeCalledWith('mousemove', expect.any(Function)); + expect(removeListener).toBeCalledWith('touchstart', expect.any(Function)); + }); + + test('should remove the controls root node', () => { + const instance = getInstance(); + + instance.destroy(); + + expect(instance.containerEl.firstChild).toBeNull(); + }); + }); + + describe('disable', () => { + test('should hide the root element', () => { + const instance = getInstance(); + instance.disable(); + + expect(instance.controlsEl.classList).toContain('bp-is-hidden'); + }); + }); + + describe('enable', () => { + test('should show the root element', () => { + const instance = getInstance(); + instance.disable(); + instance.enable(); + + expect(instance.controlsEl.classList).not.toContain('bp-is-hidden'); + }); + }); + + describe('event handlers', () => { + describe('handleMouseMove', () => { + test('should show and then hide the controls layer', () => { + const instance = getInstance(); + jest.spyOn(instance.controlsLayer, 'hide'); + jest.spyOn(instance.controlsLayer, 'reset'); + jest.spyOn(instance.controlsLayer, 'show'); + + instance.handleMouseMove(); + + expect(instance.controlsLayer.show).toBeCalled(); + expect(instance.controlsLayer.hide).toBeCalled(); + }); + }); + + describe('handleTouchStart', () => { + test('should show, reset, then hide the controls layer', () => { + const instance = getInstance(); + jest.spyOn(instance.controlsLayer, 'hide'); + jest.spyOn(instance.controlsLayer, 'reset'); + jest.spyOn(instance.controlsLayer, 'show'); + + instance.handleTouchStart(); + + expect(instance.controlsLayer.show).toBeCalled(); + expect(instance.controlsLayer.reset).toBeCalled(); + expect(instance.controlsLayer.hide).toBeCalled(); + }); + }); + }); + + describe('render', () => { + test('should create a controls layer and pass it the provided components', () => { + const controls =
Controls
; + const instance = getInstance(); + + instance.render(controls); + + expect(instance.controlsEl.firstChild).toMatchInlineSnapshot(` +
+
+ Controls +
+
+ `); + }); + }); +}); diff --git a/src/lib/viewers/controls/controls-root/index.ts b/src/lib/viewers/controls/controls-root/index.ts new file mode 100644 index 000000000..1c5ea8c63 --- /dev/null +++ b/src/lib/viewers/controls/controls-root/index.ts @@ -0,0 +1 @@ +export { default } from './ControlsRoot'; diff --git a/yarn.lock b/yarn.lock index d272b0e9c..d0b7ffdf2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1669,6 +1669,13 @@ dependencies: "@babel/types" "^7.3.0" +"@types/cheerio@*": + version "0.22.22" + resolved "https://registry.yarnpkg.com/@types/cheerio/-/cheerio-0.22.22.tgz#ae71cf4ca59b8bbaf34c99af7a5d6c8894988f5f" + integrity sha512-05DYX4zU96IBfZFY+t3Mh88nlwSMtmmzSYaQkKN48T495VV1dkHSah6qYyDTN5ngaS0i0VonH37m+RuzSM0YiA== + dependencies: + "@types/node" "*" + "@types/color-name@^1.1.1": version "1.1.1" resolved "https://registry.yarnpkg.com/@types/color-name/-/color-name-1.1.1.tgz#1c1261bbeaa10a8055bbc5d8ab84b7b2afc846a0" @@ -1679,6 +1686,14 @@ resolved "https://registry.yarnpkg.com/@types/emoji-regex/-/emoji-regex-8.0.0.tgz#df215c9ff818e071087fb8e7e6e74c4cb42a1303" integrity sha512-iacbaYN9IWWrGWTwlYLVOeUtN/e4cjN9Uh6v7Yo1Qa/vJzeSQeh10L/erBBSl53BTmbnQ07vsWp8mmNHGI4WbQ== +"@types/enzyme@^3.10.8": + version "3.10.8" + resolved "https://registry.yarnpkg.com/@types/enzyme/-/enzyme-3.10.8.tgz#ad7ac9d3af3de6fd0673773123fafbc63db50d42" + integrity sha512-vlOuzqsTHxog6PV79+tvOHFb6hq4QZKMq1lLD9MaWD1oec2lHTKndn76XOpSwCA0oFTaIbKVPrgM3k78Jjd16g== + dependencies: + "@types/cheerio" "*" + "@types/react" "*" + "@types/eslint-visitor-keys@^1.0.0": version "1.0.0" resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"