Skip to content

Commit

Permalink
feat(controls): Add react versions of core control components (#1282)
Browse files Browse the repository at this point in the history
  • Loading branch information
jstoffan authored Nov 3, 2020
1 parent 43fe1d5 commit d00879d
Show file tree
Hide file tree
Showing 15 changed files with 473 additions and 0 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/lib/types/global.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
declare const __: Function;
8 changes: 8 additions & 0 deletions src/lib/viewers/controls/controls-bar/ControlsBar.scss
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 14 additions & 0 deletions src/lib/viewers/controls/controls-bar/ControlsBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="bp-ControlsBar" {...rest}>
{children}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 = <div className="test">Hello</div>;
const wrapper = shallow(<ControlsBar>{children}</ControlsBar>);

expect(wrapper.contains(children)).toBe(true);
expect(wrapper.hasClass('bp-ControlsBar')).toBe(true);
});
});
});
1 change: 1 addition & 0 deletions src/lib/viewers/controls/controls-bar/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default } from './ControlsBar';
9 changes: 9 additions & 0 deletions src/lib/viewers/controls/controls-layer/ControlsLayer.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.bp-ControlsLayer {
display: flex;
opacity: 0;
transition: opacity .5s;

&.bp-is-visible {
opacity: 1;
}
}
85 changes: 85 additions & 0 deletions src/lib/viewers/controls/controls-layer/ControlsLayer.tsx
Original file line number Diff line number Diff line change
@@ -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<number>();

// 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 (
<div
className={`bp-ControlsLayer ${isShown ? SHOW_CLASSNAME : ''}`}
onBlur={handleFocusOut}
onFocus={handleFocusIn}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
{children}
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 = <div className="TestControls">Controls</div>;
const getElement = (wrapper: ReactWrapper): ReactWrapper => wrapper.childAt(0);
const getWrapper = (props = {}): ReactWrapper => mount(<ControlsLayer {...props}>{children}</ControlsLayer>);

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);
});
});
});
2 changes: 2 additions & 0 deletions src/lib/viewers/controls/controls-layer/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './ControlsLayer';
export { default } from './ControlsLayer';
13 changes: 13 additions & 0 deletions src/lib/viewers/controls/controls-root/ControlsRoot.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
71 changes: 71 additions & 0 deletions src/lib/viewers/controls/controls-root/ControlsRoot.tsx
Original file line number Diff line number Diff line change
@@ -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(<ControlsLayer onMount={this.handleMount}>{controls}</ControlsLayer>, this.controlsEl);
}
}
Loading

0 comments on commit d00879d

Please sign in to comment.