ModalInMobile opens page fragment as Modal in given breakpoint and removes scrolling from PageLayout.

This commit is contained in:
Vesa Luusua 2017-04-04 16:18:41 +03:00
parent 8971c9b29a
commit 8cf00d218c
14 changed files with 383 additions and 8 deletions

View file

@ -0,0 +1,58 @@
/* Content is visible as modal layer */
.modal {
display: block;
position: fixed;
top: 0;
height: 100vh;
width: 100%;
z-index: 100;
background-color: white;
/* Mobile frame fix - this needs to be removed */
max-width: 375px;
margin: 0 auto;
}
/* Content is hidden in Mobile layout */
.modalHidden {
display: none;
}
/* This class is passed to page layout level to fix scrolling while modal is open */
.modalInMobileOpen {
height: 100vh;
overflow: hidden;
}
/* Header section for Modal */
.header {
display: flex;
flex-direction: row;
justify-content: flex-start;
}
.title {
margin: 1rem 0;
}
/* This button skin should come from global style configurations */
.close {
width: auto;
height: 3em;
background-color: transparent;
padding: 1em;
color: #999;
border: 0;
&:enabled:hover {
background-color: transparent;
color: #666;
}
&:enabled:active {
background-color: transparent;
color: #000;
}
&:disabled {
background-color: transparent;
}
}

View file

@ -0,0 +1,9 @@
.visibleOnMobileLayout {
display: none;
}
@media (max-width: 400px) {
.visibleOnMobileLayout {
display: block;
}
}

View file

@ -0,0 +1,51 @@
/* eslint-disable no-console, import/prefer-default-export */
import React, { Component, PropTypes } from 'react';
import { Button } from '../../components';
import ModalInMobile from './ModalInMobile';
import css from './ModalInMobile.example.css';
class ModalInMobileWrapper extends Component {
constructor(props) {
super(props);
this.state = { isOpen: false };
this.handleOpen = this.handleOpen.bind(this);
}
getChildContext() {
return {
togglePageClassNames: (className, addClass = true) => {
// We are just checking the value for now
console.log('Toggling ModalInMobile - currently:', className, addClass);
},
};
}
handleOpen() {
this.setState({ isOpen: true });
}
render() {
return (
<div>
Wrapper text before ModalInMobile
<ModalInMobile {...this.props} isModalOpenOnMobile={this.state.isOpen}>
Some content inside ModalInMobile component
</ModalInMobile>
<Button onClick={this.handleOpen} className={css.visibleOnMobileLayout}>Open</Button>
</div>
);
}
}
ModalInMobileWrapper.childContextTypes = { togglePageClassNames: PropTypes.func.isRequired };
export const Empty = {
component: ModalInMobileWrapper,
props: {
onClose() {
console.log('Closing modal');
},
showAsModalMaxWidth: 400,
title: 'Test ModalInMobile',
},
};

View file

@ -0,0 +1,142 @@
/**
* ModalInMobile gives possibility separate part of existing DOM so that in mobile views that
* fragment is shown in a separate modal layer on top of the page.
*
* Currently, this does not implement resize listener for window.
*
* Example:
* <Parent>
* <ModalInMobile isModalOpenOnMobile={this.state.modalOpen} onClose={handleClose}>
* <FormX />
* </ModalInMobile>
* </Parent>
*/
import React, { Component, PropTypes } from 'react';
import classNames from 'classnames';
import { intlShape, injectIntl } from 'react-intl';
import { Button } from '../../components';
import { withTogglePageClassNames } from '../../util/contextHelpers';
import css from './ModalInMobile.css';
export class ModalInMobileComponent extends Component {
constructor(props) {
super(props);
this.state = {
isOpen: false,
};
this.handleClose = this.handleClose.bind(this);
this.changeOpenStatus = this.changeOpenStatus.bind(this);
}
componentDidMount() {
const { isModalOpenOnMobile, showAsModalMaxWidth } = this.props;
// After Mounting, component can adapt to responsive screen size
const shouldShowAsModal = window.matchMedia
? window.matchMedia(`(max-width: ${showAsModalMaxWidth}px)`).matches
: false;
if (shouldShowAsModal && isModalOpenOnMobile) {
this.changeOpenStatus(isModalOpenOnMobile);
}
}
componentWillReceiveProps(nextProps) {
const { isModalOpenOnMobile, showAsModalMaxWidth } = nextProps;
const isChanging = isModalOpenOnMobile !== this.state.isOpen;
const shouldShowAsModal = window.matchMedia
? window.matchMedia(`(max-width: ${showAsModalMaxWidth}px)`).matches
: false;
const shouldBeClosedAsModal = !shouldShowAsModal && !isModalOpenOnMobile;
// Handle change if status is changing on mobile layout or it is closing (on desktop layout)
if (isChanging && (shouldShowAsModal || shouldBeClosedAsModal)) {
this.changeOpenStatus(isModalOpenOnMobile);
}
}
changeOpenStatus(isOpen) {
const { togglePageClassNames } = this.props;
togglePageClassNames(css.modalInMobileOpen, isOpen);
this.setState({ isOpen });
}
handleClose(event) {
const { onClose } = this.props;
this.changeOpenStatus(false);
if (onClose) {
onClose(event);
}
}
render() {
const {
children,
className,
intl,
onClose,
showAsModalMaxWidth,
title,
} = this.props;
const isMobileLayout = typeof window !== 'undefined' && window.matchMedia
? window.matchMedia(`(max-width: ${showAsModalMaxWidth}px)`).matches
: false;
const isOpenInMobile = this.state.isOpen;
const isClosedInMobile = isMobileLayout && !isOpenInMobile;
const closeModalMessage = intl.formatMessage({ id: 'ModalInMobile.closeModal' });
const modalTitle = title ? <h2 className={css.title}>{title}</h2> : null;
const closeBtn = onClose && isOpenInMobile
? <Button onClick={this.handleClose} className={css.close} title={closeModalMessage}>
</Button>
: null;
const header = isOpenInMobile && (closeBtn || modalTitle)
? <div className={css.header}>
{closeBtn}
{modalTitle}
</div>
: null;
// We have 3 view states:
// - default desktop layout (just an extra wrapper)
// - mobile layout: content visible inside modal popup
// - mobile layout: content hidden
const classes = classNames(
{ [css.modal]: isOpenInMobile, [css.modalHidden]: isClosedInMobile },
className
);
return (
<div className={classes}>
{header}
{children}
</div>
);
}
}
ModalInMobileComponent.defaultProps = {
children: null,
className: '',
onClose: null,
showAsModalMaxWidth: 0,
title: null,
};
const { bool, func, node, number, string } = PropTypes;
ModalInMobileComponent.propTypes = {
children: node,
className: string,
intl: intlShape.isRequired,
isModalOpenOnMobile: bool.isRequired,
onClose: func,
showAsModalMaxWidth: number,
title: string,
// eslint-disable-next-line react/no-unused-prop-types
togglePageClassNames: func.isRequired,
};
export default withTogglePageClassNames(injectIntl(ModalInMobileComponent));

View file

@ -0,0 +1,18 @@
import React from 'react';
import { fakeIntl } from '../../util/test-data';
import { renderDeep } from '../../util/test-helpers';
import { ModalInMobileComponent } from './ModalInMobile';
describe('ModalInMobile', () => {
it('no extra classes when window is missing', () => {
const props = {
className: 'test-class-from-props',
intl: fakeIntl,
isModalOpenOnMobile: false,
togglePageClassNames: v => v,
};
const tree = renderDeep(<ModalInMobileComponent {...props}>Content</ModalInMobileComponent>);
expect(tree).toMatchSnapshot();
});
});

View file

@ -0,0 +1,6 @@
exports[`ModalInMobile no extra classes when window is missing 1`] = `
<div
className="test-class-from-props">
Content
</div>
`;

View file

@ -3,6 +3,7 @@ import { connect } from 'react-redux';
import Helmet from 'react-helmet';
import { withRouter } from 'react-router-dom';
import { FormattedMessage } from 'react-intl';
import { union, without } from 'lodash';
import classNames from 'classnames';
import { Topbar } from '../../containers';
@ -14,6 +15,18 @@ const scrollToTop = () => {
};
class PageLayout extends Component {
constructor(props) {
super(props);
this.state = {
pageClassNames: '',
};
this.togglePageClassNames = this.togglePageClassNames.bind(this);
}
getChildContext() {
return { togglePageClassNames: this.togglePageClassNames };
}
componentDidMount() {
this.historyUnlisten = this.props.history.listen(() => scrollToTop());
}
@ -24,6 +37,17 @@ class PageLayout extends Component {
}
}
togglePageClassNames(className, addClass = true) {
this.setState(prevState => {
const prevPageClassNames = prevState.pageClassNames.split(' ');
const pageClassNames = addClass
? union(prevPageClassNames, [className]).join(' ')
: without(prevPageClassNames, className).join(' ');
return { pageClassNames };
});
}
render() {
const { className, title, children, authInfoError, logoutError } = this.props;
@ -39,7 +63,7 @@ class PageLayout extends Component {
/* eslint-enable no-console */
return (
<div className={classNames(css.root, className)}>
<div className={classNames(css.root, className, this.state.pageClassNames)}>
<Helmet>
<title>{title}</title>
</Helmet>
@ -64,6 +88,8 @@ class PageLayout extends Component {
const { any, string, instanceOf, func, shape } = PropTypes;
PageLayout.childContextTypes = { togglePageClassNames: func.isRequired };
PageLayout.defaultProps = { className: '', children: null, authInfoError: null, logoutError: null };
PageLayout.propTypes = {

View file

@ -1,6 +1,7 @@
import React from 'react';
import { currencyConfig } from '../../util/test-data';
import { fakeIntl, renderShallow } from '../../util/test-helpers';
import { fakeIntl } from '../../util/test-data';
import { renderShallow } from '../../util/test-helpers';
import SearchResultsPanel from './SearchResultsPanel';
describe('SearchResultsPanel', () => {

View file

@ -14,6 +14,7 @@ import Map from './Map/Map';
import MapPanel from './MapPanel/MapPanel';
import Menu from './Menu/Menu';
import MobileFrame from './MobileFrame/MobileFrame';
import ModalInMobile from './ModalInMobile/ModalInMobile';
import NamedLink from './NamedLink/NamedLink';
import NamedRedirect from './NamedRedirect/NamedRedirect';
import OrderDetailsPanel from './OrderDetailsPanel/OrderDetailsPanel';
@ -41,6 +42,7 @@ export {
MapPanel,
Menu,
MobileFrame,
ModalInMobile,
NamedLink,
NamedRedirect,
OrderDetailsPanel,

View file

@ -4,6 +4,7 @@ import * as BookingInfo from './components/BookingInfo/BookingInfo.example';
import * as CurrencyInput from './components/CurrencyInput/CurrencyInput.example';
import * as ListingCard from './components/ListingCard/ListingCard.example';
import * as Map from './components/Map/Map.example';
import * as ModalInMobile from './components/ModalInMobile/ModalInMobile.example';
import * as NamedLink from './components/NamedLink/NamedLink.example';
import * as LocationAutocompleteInput
from './components/LocationAutocompleteInput/LocationAutocompleteInput.example';
@ -33,6 +34,7 @@ export {
LocationAutocompleteInput,
LoginForm,
Map,
ModalInMobile,
NamedLink,
PasswordForgottenForm,
SignUpForm,

View file

@ -16,6 +16,7 @@
"HeroSearchForm.search": "Search",
"HeroSection.subTitle": "The largest online community to rent music studios",
"HeroSection.title": "Book Studiotime anywhere",
"ModalInMobile.closeModal": "Close modal",
"ListingPage.loadingListingData": "Loading listing data",
"ListingPage.noListingData": "Could not find listing data",
"PageLayout.authInfoFailed": "Could not get authentication information.",

View file

@ -5,3 +5,42 @@ exports[`util/contextHelpers.js withFlattenedRoutes should inject the provided r
SomePage
</div>
`;
exports[`util/contextHelpers.js withTogglePageClassNames should inject the provided function 1`] = `
<div
className="">
<div
className={undefined}>
<div>
<a
className="NamedLink_active"
href="/"
onClick={[Function]}
style={Object {}}>
<span
dangerouslySetInnerHTML={
Object {
"__html": "&#127968;",
}
} />
</a>
</div>
<div
className={undefined}>
<a
className=""
href="/login"
onClick={[Function]}
style={Object {}}>
Login
</a>
</div>
</div>
<div
className={undefined}>
<div>
function
</div>
</div>
</div>
`;

View file

@ -23,3 +23,23 @@ export const withFlattenedRoutes = Component => {
return WithFlattenedRoutesComponent;
};
/**
* A higher order component (HOC) to take the togglePageClassNames function from
* the context that the PageLayout component has provided.
*/
export const withTogglePageClassNames = Component => {
const WithTogglePageClassNamesComponent = (props, context) => (
<Component togglePageClassNames={context.togglePageClassNames} {...props} />
);
WithTogglePageClassNamesComponent.displayName = `withTogglePageClassNames(${Component.displayName || Component.name})`;
const { func } = PropTypes;
WithTogglePageClassNamesComponent.contextTypes = {
togglePageClassNames: func.isRequired,
};
return WithTogglePageClassNamesComponent;
};

View file

@ -4,7 +4,7 @@ import routesConfiguration from '../routesConfiguration';
import { flattenRoutes } from './routes';
import { renderDeep, renderShallow } from './test-helpers';
import * as propTypes from './propTypes';
import { withFlattenedRoutes, withTogglePageClasses } from './contextHelpers';
import { withFlattenedRoutes, withTogglePageClassNames } from './contextHelpers';
const { arrayOf, func } = PropTypes;
@ -30,15 +30,15 @@ describe('util/contextHelpers.js', () => {
});
});
describe('withTogglePageClasses', () => {
describe('withTogglePageClassNames', () => {
it('should inject the provided function', () => {
const CompComp = props => <div>{typeof props.togglePageClasses}</div>;
CompComp.propTypes = { togglePageClasses: func.isRequired };
const Comp = withTogglePageClasses(CompComp);
const CompComp = props => <div>{typeof props.togglePageClassNames}</div>;
CompComp.propTypes = { togglePageClassNames: func.isRequired };
const Comp = withTogglePageClassNames(CompComp);
const deepTree = renderDeep(
<RoutesProvider flattenedRoutes={flattenRoutes(routesConfiguration)}>
<PageLayout title="testing withTogglePageClasses">
<PageLayout title="testing withTogglePageClassNames">
<Comp />
</PageLayout>
</RoutesProvider>