mirror of
https://github.com/kingomarnajjar/flex-template-web.git
synced 2026-07-29 05:00:43 +10:00
ModalInMobile opens page fragment as Modal in given breakpoint and removes scrolling from PageLayout.
This commit is contained in:
parent
8971c9b29a
commit
8cf00d218c
14 changed files with 383 additions and 8 deletions
58
src/components/ModalInMobile/ModalInMobile.css
Normal file
58
src/components/ModalInMobile/ModalInMobile.css
Normal 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;
|
||||
}
|
||||
}
|
||||
9
src/components/ModalInMobile/ModalInMobile.example.css
Normal file
9
src/components/ModalInMobile/ModalInMobile.example.css
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
.visibleOnMobileLayout {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 400px) {
|
||||
.visibleOnMobileLayout {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
51
src/components/ModalInMobile/ModalInMobile.example.js
Normal file
51
src/components/ModalInMobile/ModalInMobile.example.js
Normal 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',
|
||||
},
|
||||
};
|
||||
142
src/components/ModalInMobile/ModalInMobile.js
Normal file
142
src/components/ModalInMobile/ModalInMobile.js
Normal 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));
|
||||
18
src/components/ModalInMobile/ModalInMobile.test.js
Normal file
18
src/components/ModalInMobile/ModalInMobile.test.js
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
exports[`ModalInMobile no extra classes when window is missing 1`] = `
|
||||
<div
|
||||
className="test-class-from-props">
|
||||
Content
|
||||
</div>
|
||||
`;
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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": "🏠",
|
||||
}
|
||||
} />
|
||||
</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>
|
||||
`;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue