Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add overlay component #3466

Merged
merged 17 commits into from
Aug 29, 2017
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,16 @@ type Props = {
isOpen: boolean,
/** When set to false the backdrop renders transparent. */
isVisible: boolean,
/** If true, the backdrop gets rendered in the placed element and not in the body. */
local: boolean,
onClick?: () => void,
};

export default class Backdrop extends React.PureComponent<Props> {
static defaultProps = {
isOpen: true,
isVisible: true,
local: false,
};

handleClick = () => {
Expand All @@ -31,13 +35,10 @@ export default class Backdrop extends React.PureComponent<Props> {
[backdropStyles.backdrop]: true,
[backdropStyles.isVisible]: isVisible,
});

return (
<Portal isOpened={isOpen}>
<div
onClick={this.handleClick}
className={backdropClasses} />
</Portal>
);
const backdrop = <div onClick={this.handleClick} className={backdropClasses} />;
if (this.props.local) {
return backdrop;
}
return <Portal isOpened={isOpen}>{backdrop}</Portal>;
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
The Backdrop component serves as a simple solution to create a backdrop for modals and other kinds of overlays.
The Backdrop component serves as a simple solution to create a backdrop for overlays.

Here is a basic example of the component. The open state of the backdrop is controlled by the `isOpen` property.

Expand All @@ -7,7 +7,7 @@ intialState = {open: false};

<div>
<button onClick={() => setState({open: true})}>Open Backdrop</button>
<Backdrop isOpen={state.open} onClick={() => setState({open: false})} />
<Backdrop isOpen={!!state.open} onClick={() => setState({open: false})} />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the double bang?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because initially the state.open is undefined in styleguidist. As the default value for isOpen is true the Backdrop opens automatically. That's why the backdrops were open on page load all the time.

</div>
```

Expand All @@ -18,6 +18,6 @@ intialState = {open: false};

<div>
<button onClick={() => setState({open: true})}>Open Backdrop</button>
<Backdrop isVisible={false} isOpen={state.open} onClick={() => setState({open: false})} />
<Backdrop isVisible={false} isOpen={!!state.open} onClick={() => setState({open: false})} />
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why the double bang? We actually said we don't do them anymore.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The make sense sometimes. Especially at the points where you really expect a boolean and not just a truthy type. At this point undefined and false make a difference.

</div>
```
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
@import '../../containers/Application/colors.scss';

$backdropBackground: $black;
$backdropBackground: rgba($dustyGray, .9);

.backdrop {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: $backdropBackground;
opacity: 0;
background: transparent;

&.is-visible {
opacity: .4;
background: $backdropBackground;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ test('The component should render in body when open', () => {
expect(pretty(body.innerHTML)).toMatchSnapshot();
});

test('The component should not render in body when local property is set', () => {
const view = mount(<Backdrop local={true} />).render();
expect(view).toMatchSnapshot();
});

test('The component should not render in the body when closed', () => {
const body = document.body;
const view = mount(<Backdrop isOpen={false} />).render();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`The component should not render in body when local property is set 1`] = `
<div
class="backdrop isVisible"
/>
`;

exports[`The component should render in body when open 1`] = `
"<div>
<div data-reactroot=\\"\\" class=\\"backdrop isVisible\\"></div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export default class GenericSelect extends React.PureComponent<Props> {
};

handleDisplayValueClick = this.openList;
handleListRequestClose = this.closeList;
handleListClose = this.closeList;
setDisplayValue = (displayValue: ?ElementRef<typeof DisplayValue>) => this.displayValue = displayValue;

render() {
Expand All @@ -68,7 +68,7 @@ export default class GenericSelect extends React.PureComponent<Props> {
anchorHeight={displayValueDimensions.height}
isOpen={this.isOpen}
centeredChildIndex={this.centeredChildIndex}
onRequestClose={this.handleListRequestClose}>
onClose={this.handleListClose}>
{listChildren}
</OverlayList>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import OverlayListPositioner from './OverlayListPositioner';
type Props = {
isOpen: boolean,
children: SelectChildren,
onRequestClose?: () => void,
onClose?: () => void,
/** The top coordinate relative to which the list will be positioned */
anchorTop: number,
/** The left coordinate relative to which the list will be positioned */
Expand Down Expand Up @@ -50,13 +50,13 @@ export default class OverlayList extends React.PureComponent<Props> {
}

componentDidMount() {
window.addEventListener('blur', this.requestClose);
window.addEventListener('resize', this.requestClose);
window.addEventListener('blur', this.close);
window.addEventListener('resize', this.close);
}

componentWillUnmount() {
window.removeEventListener('blur', this.requestClose);
window.removeEventListener('resize', this.requestClose);
window.removeEventListener('blur', this.close);
window.removeEventListener('resize', this.close);
}

@action componentWillReceiveProps(newProps: Props) {
Expand All @@ -73,9 +73,9 @@ export default class OverlayList extends React.PureComponent<Props> {
});
}

requestClose = () => {
if (this.props.isOpen && this.props.onRequestClose) {
this.props.onRequestClose();
close = () => {
if (this.props.isOpen && this.props.onClose) {
this.props.onClose();
}
};

Expand Down Expand Up @@ -110,7 +110,7 @@ export default class OverlayList extends React.PureComponent<Props> {
}));
};

handleBackropClick = this.requestClose;
handleBackropClick = this.close;

render() {
let style = {opacity: '0'};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,28 +51,28 @@ test('The list should not render in body when not open', () => {
});

test('The list should request to be closed when the backdrop is clicked', () => {
const onRequestCloseSpy = jest.fn();
const onCloseSpy = jest.fn();
const list = shallow(
<OverlayList isOpen={true} onRequestClose={onRequestCloseSpy}>
<OverlayList isOpen={true} onClose={onCloseSpy}>
<Option value="option-1">My option 1</Option>
</OverlayList>
);
list.find('Backdrop').simulate('click');
expect(onRequestCloseSpy).toBeCalled();
expect(onCloseSpy).toBeCalled();
});

test('The list should request to be closed when the window is blurred', () => {
const windowListeners = {};
window.addEventListener = jest.fn((event, cb) => windowListeners[event] = cb);
const onRequestCloseSpy = jest.fn();
const onCloseSpy = jest.fn();
mount(
<OverlayList isOpen={true} onRequestClose={onRequestCloseSpy}>
<OverlayList isOpen={true} onClose={onCloseSpy}>
<Option value="option-1">My option 1</Option>
</OverlayList>
).render();
expect(windowListeners.blur).toBeDefined();
windowListeners.blur();
expect(onRequestCloseSpy).toBeCalled();
expect(onCloseSpy).toBeCalled();
});

test('The list should take its dimensions from the positioner', () => {
Expand Down
10 changes: 4 additions & 6 deletions src/Sulu/Bundle/AdminBundle/Resources/js/components/Icon/Icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,17 @@ import classNames from 'classnames';

type Props = {
className?: string,
onClick?: () => void,
name: string,
};

export default class Icon extends React.PureComponent<Props> {
render() {
const className = classNames(
this.props.className,
'fa',
'fa-' + this.props.name
);
const {className, name, onClick} = this.props;
const classes = classNames(className, 'fa', 'fa-' + name);

return (
<span className={className} aria-hidden={true} />
<span className={classes} aria-hidden={true} onClick={onClick} />
);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable flowtype/require-valid-file-annotation */
import React from 'react';
import {render} from 'enzyme';
import {render, shallow} from 'enzyme';
import Icon from '../Icon';

test('Icon should render', () => {
Expand All @@ -10,3 +10,10 @@ test('Icon should render', () => {
test('Icon should render with class names', () => {
expect(render(<Icon className="test" name="edit" />)).toMatchSnapshot();
});

test('Icon should call the callback on click', () => {
const onClick = jest.fn();
const icon = shallow(<Icon className="test" name="edit" onClick={onClick} />);
icon.simulate('click');
expect(onClick).toBeCalled();
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// @flow
import React from 'react';
import type {Action} from './types';
import actionsStyles from './actions.scss';

type Props = {
actions: Array<Action>,
};

export default class Actions extends React.PureComponent<Props> {
render() {
const {actions} = this.props;
if (!actions.length) {
return null;
}

return (
<div className={actionsStyles.actions}>
{actions.map((action, index) => {
const handleButtonClick = action.onClick;
return (
<button
key={index}
className={actionsStyles.action}
onClick={handleButtonClick}>{action.title}</button>
);
})}
</div>
);
}
}
116 changes: 116 additions & 0 deletions src/Sulu/Bundle/AdminBundle/Resources/js/components/Overlay/Overlay.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
// @flow
import classNames from 'classnames';
import Mousetrap from 'mousetrap';
import {observable, action} from 'mobx';
import {observer} from 'mobx-react';
import type {Node} from 'react';
import React from 'react';
import Portal from 'react-portal';
import Icon from '../Icon';
import {afterElementsRendered} from '../../services/DOM';
import Backdrop from '../Backdrop';
import type {Action} from './types';
import Actions from './Actions';
import overlayStyles from './overlay.scss';

type Props = {
title: string,
children: Node,
actions: Array<Action>,
confirmText: string,
onConfirm: () => void,
isOpen: boolean,
onClose: () => void,
};

const CLOSE_ICON = 'times';

@observer
export default class Overlay extends React.PureComponent<Props> {
static defaultProps = {
isOpen: false,
actions: [],
};

@observable isVisible: boolean = false;
@observable isOpenHasChanged: boolean = false;

@action componentWillMount() {
Mousetrap.bind('esc', this.close);
this.isOpenHasChanged = this.props.isOpen;
}

componentWillUnmount() {
Mousetrap.unbind('esc', this.close);
}

componentDidMount() {
this.toggle();
}

@action componentWillReceiveProps(newProps: Props) {
this.isOpenHasChanged = newProps.isOpen !== this.props.isOpen;
}

componentDidUpdate() {
this.toggle();
}

close = () => {
this.props.onClose();
};

@action toggle() {
afterElementsRendered(action(() => {
if (this.isOpenHasChanged) {
this.isVisible = this.props.isOpen;
}
}));
}

@action handleTransitionEnd = () => {
afterElementsRendered(action(() => {
this.isOpenHasChanged = false;
}));
};

handleIconClick = () => {
this.close();
};

render() {
const containerClass = classNames({
[overlayStyles.container]: true,
[overlayStyles.isDown]: this.isVisible,
});
const {isOpen, title, actions, onConfirm, confirmText, children} = this.props;

return (
<Portal isOpened={isOpen || this.isOpenHasChanged}>
<div
className={containerClass}
onTransitionEnd={this.handleTransitionEnd}>
<div className={overlayStyles.overlay}>
<section className={overlayStyles.content}>
<header>
{title}
<Icon
name={CLOSE_ICON}
className={overlayStyles.icon}
onClick={this.handleIconClick} />
</header>
<article>{children}</article>
<footer>
<Actions actions={actions} />
<button className={overlayStyles.confirmButton} onClick={onConfirm}>
{confirmText}
</button>
</footer>
</section>
</div>
<Backdrop local={true} onClick={this.props.onClose} />
</div>
</Portal>
);
}
}