-
Notifications
You must be signed in to change notification settings - Fork 116
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(controls): Add react versions of core control components (#1282)
- Loading branch information
Showing
15 changed files
with
473 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
declare const __: Function; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
15 changes: 15 additions & 0 deletions
15
src/lib/viewers/controls/controls-bar/__tests__/ControlsBar-test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export { default } from './ControlsBar'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
115 changes: 115 additions & 0 deletions
115
src/lib/viewers/controls/controls-layer/__tests__/ControlsLayer-test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from './ControlsLayer'; | ||
export { default } from './ControlsLayer'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} | ||
} |
Oops, something went wrong.