UNPKG

wix-style-react

Version:
656 lines (581 loc) • 19.5 kB
import React from 'react'; import { WixStyleReactContext } from '../WixStyleReactProvider/context'; import PropTypes from 'prop-types'; import { ResizeSensor } from 'css-element-queries'; import { st, classes, stVars, vars } from './Page.st.css'; import { PageContext } from './PageContext'; import PageHeader from '../PageHeader'; import PageSection from '../PageSection'; import Content from './Content'; import Tail from './Tail'; import { PageSticky } from './PageSticky'; import FixedFooter from './FixedFooter'; import ScrollableContainer from '../common/ScrollableContainer'; import { ScrollableContainerCommonProps } from '../common/PropTypes/ScrollableContainerCommon'; /* * Page structure without mini-header-overlay: * * + PageWrapper (Horizontal Scroll) -- * | +- Page -------------------------- * | | +-- ScrollableContainer (Vertical Scroll) * | | | +-- MinimizationPlaceholder * | | | | * | | | +----------------------------- * | | | +-- HeaderContainer ------ (position: fixed - when minimized) * | | | | +-- Page.Header ------------ * | | | | | * | | | | +--------------------------- * | | | | +-- Page.Tail -------------- * | | | | | * | | | | +--------------------------- * | | | +----------------------------- * | | | +-- ContentWrapper------------ * | | | | +-- Page.FixedContent (Deprecated) * | | | | | * | | | | +--------------------------- * | | | | +-- Page.Content ----------- * | | | | | +-- Page.Section --------- * | | | | | * | | | | +--------------------------- * | | | +----------------------------- * | | +------------------------------- * | +--------------------------------- (Page - End) * +----------------------------------- (PageWrapper - End) * * - ScrollableContainer has a data-classnamed 'scrollable-content', and should NOT be renamed, since * Tooltip is hard-coded-ly using a selector like this: [data-class="page-scrollable-content"] */ class Page extends React.PureComponent { static defaultProps = { minWidth: parseInt(stVars.mainContainerMinWidth, 10), maxWidth: parseInt(stVars.mainContainerMaxWidth, 10), scrollProps: {}, }; constructor(props) { super(props); this.scrollableContainerRef = React.createRef(); this._handleScroll = this._handleScroll.bind(this); this._handleWidthResize = this._handleWidthResize.bind(this); this._handleWindowResize = this._handleWindowResize.bind(this); this._calculateComponentsHeights = this._calculateComponentsHeights.bind(this); this.state = { headerContainerHeight: 0, headerWrapperHeight: 0, tailHeight: 0, footerHeight: 0, minimized: false, }; } componentDidMount() { this._calculateComponentsHeights(); this.contentResizeListener = new ResizeSensor( this._getScrollContainer().childNodes[0], this._handleWidthResize, ); this._handleWidthResize(); window.addEventListener('resize', this._handleWindowResize); // TODO: Hack to fix cases where initial measurement of headerWrapperHeight is not correct (need to investigate) // Happens in PageTestStories -> PageWithScroll -> 5. Scroll - Trigger Mini Header // Maybe there is a transition const ARBITRARY_SHORT_DURATION_MS = 100; setTimeout(this._calculateComponentsHeights, ARBITRARY_SHORT_DURATION_MS); // This is done for backward compatibility only, // Notifying current users that passed the `scrollableContentRef` prop about the ref current value. // New users should be encouraged to use the new event handlers onScrollChanged/onScrollAreaChanged // according to their use case. this.props.scrollableContentRef && this.props.scrollableContentRef(this.scrollableContainerRef.current); } componentDidUpdate(prevProps) { this._calculateComponentsHeights(); } componentWillUnmount() { window.removeEventListener('resize', this._handleWindowResize); this.contentResizeListener.detach(this._handleResize); } _getNamedChildren() { return getChildrenObject(this.props.children); } _calculateComponentsHeights() { const { headerContainerHeight, headerWrapperHeight, tailHeight, pageHeight, footerHeight, minimized, } = this.state; const newHeaderWrapperHeight = this.headerWrapperRef && !minimized ? this.headerWrapperRef.getBoundingClientRect().height : headerWrapperHeight; const newHeaderContainerHeight = this.headerWrapperRef && !minimized ? this.headerContainerRef.getBoundingClientRect().height : headerContainerHeight; const newTailHeight = this.pageHeaderTailRef ? this.pageHeaderTailRef.offsetHeight : 0; const newPageHeight = this.pageRef ? this.pageRef.offsetHeight : 0; const newFooterHeight = this.footerWrapperRef ? this.footerWrapperRef.offsetHeight : 0; if ( headerContainerHeight !== newHeaderContainerHeight || headerWrapperHeight !== newHeaderWrapperHeight || tailHeight !== newTailHeight || pageHeight !== newPageHeight || footerHeight !== newFooterHeight ) { this.setState({ headerContainerHeight: newHeaderContainerHeight, headerWrapperHeight: newHeaderWrapperHeight, tailHeight: newTailHeight, pageHeight: newPageHeight, footerHeight: newFooterHeight, }); } } _getScrollContainer() { return this.scrollableContainerRef.current; } _getMinimizedHeaderWrapperHeight() { if (!this._hasHeader()) { return 0; } return this._hasTail() ? parseInt(stVars.minimizedHeaderWrapperWithTailHeightPx, 10) : parseInt(stVars.minimizedHeaderWrapperHeightPx, 10); } _getMinimizationDiff() { const { headerWrapperHeight } = this.state; return headerWrapperHeight ? headerWrapperHeight - this._getMinimizedHeaderWrapperHeight() : null; } _handleScroll(e) { const containerScrollTop = this._getScrollContainer().scrollTop; const { minimized } = this.state; const minimizationDiff = this._getMinimizationDiff(); const nextDisplayMiniHeader = minimizationDiff && containerScrollTop >= minimizationDiff; if (minimized !== nextDisplayMiniHeader) { this.setState({ minimized: nextDisplayMiniHeader, }); } const { scrollProps: { onScrollChanged }, } = this.props; if (onScrollChanged) { onScrollChanged(e); } } _handleWidthResize() {} _handleWindowResize() { // TODO: Optimize : https://developer.mozilla.org/en-US/docs/Web/Events/resize // Taken from here: https://github.com/kunokdev/react-window-size-listener/blob/d64c077fba4d4e0ce060464078c5fc19620528e6/src/index.js#L66 const windowHeight = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight; if (this.state.windowHeight !== windowHeight) { // We are not using windowHeight directly, since we need to measure the `<Page/>`'s height, // But we hold it in the state to avoid rendering when only window.width changes this.setState({ windowHeight }); } } _safeGetChildren(element) { if (!element || !element.props || !element.props.children) { return []; } return element.props.children; } _getPageDimensionsStyle() { const { maxWidth, sidePadding } = this.props; // TODO: Simplify - maxWidth is always truthy (from defaultProp) if (!maxWidth && !sidePadding && sidePadding !== 0) { return null; } const styles = {}; if (maxWidth) { styles.maxWidth = `${maxWidth}px`; } if (sidePadding || sidePadding === 0) { styles.paddingLeft = `${sidePadding}px`; styles.paddingRight = `${sidePadding}px`; } return styles; } _hasBackgroundImage() { return !!this.props.backgroundImageUrl; } _hasGradientClassName() { return !!this.props.gradientClassName && !this.props.backgroundImageUrl; } _renderContentHorizontalLayout(props) { const { PageContent } = this._getNamedChildren(); const contentFullScreen = PageContent && PageContent.props.fullScreen; const { className, horizontalScroll, style, ...rest } = props; const pageDimensionsStyle = contentFullScreen ? null : this._getPageDimensionsStyle(); return ( <div className={st( classes.contentHorizontalLayout, { contentFullWidth: contentFullScreen, horizontalScroll, }, className, )} style={{ ...pageDimensionsStyle, ...style }} {...rest} > {props.children} </div> ); } _renderHeader() { const { minimized } = this.state; const { PageHeader: PageHeaderChild } = this._getNamedChildren(); const dataHook = 'page-header-wrapper'; return ( PageHeaderChild && ( <WixStyleReactContext.Consumer key={dataHook}> {({ reducedSpacingAndImprovedLayout }) => ( <div data-hook={dataHook} className={st(classes.headerWrapper, { reducedSpacingAndImprovedLayout, minimized, })} ref={ref => (this.headerWrapperRef = ref)} > {React.cloneElement(PageHeaderChild, { minimized, hasBackgroundImage: this._hasBackgroundImage(), })} </div> )} </WixStyleReactContext.Consumer> ) ); } _renderHeaderContainer() { const { minimized } = this.state; // placeholder when header is minimized const placeholder = ( <div key={'placeholder'} style={{ height: `${minimized ? this._getMinimizationDiff() : 0}px`, }} /> ); /** * HeaderContainer has position sticky. The `top` value is negative, in order to let * the container scroll out of view before the minimization occurs. */ const top = minimized ? `-${this._getMinimizationDiff()}px` : 0; return ( <div data-hook="page-header-container" className={st(classes.pageHeaderContainer, { minimized, hasTail: this._hasTail(), })} style={{ [vars.minimizationTop]: top, }} ref={ref => (this.headerContainerRef = ref)} > {this._renderContentHorizontalLayout({ children: [placeholder, this._renderHeader(), this._renderTail()], })} </div> ); } _renderScrollableContainer() { const { scrollProps: { onScrollAreaChanged }, } = this.props; return ( <ScrollableContainer className={st(classes.scrollableContainer, { hasTail: this._hasTail(), })} dataHook="page-scrollable-content" data-class="page-scrollable-content" ref={this.scrollableContainerRef} onScrollAreaChanged={onScrollAreaChanged} onScrollChanged={this._handleScroll} > <div data-hook="safari-12-13-sticky-fix"> {this._renderScrollableBackground()} {this._renderHeaderContainer()} {this._renderContentContainer()} {this._renderFixedFooter()} </div> </ScrollableContainer> ); } _hasTail() { return !!this._getNamedChildren().PageTail; } _hasHeader() { return !!this._getNamedChildren().PageHeader; } _renderScrollableBackground() { const { headerContainerHeight, tailHeight } = this.state; const backgroundHeight = `${ headerContainerHeight - tailHeight + (this._hasTail() ? 0 : parseInt(stVars.backgroundCoverContentPx, 10)) }px`; if (this._hasBackgroundImage()) { return ( <div className={classes.imageBackgroundContainer} style={{ height: backgroundHeight }} data-hook="page-background-image" > <div className={classes.imageBackground} style={{ backgroundImage: `url(${this.props.backgroundImageUrl})` }} /> </div> ); } if (this._hasGradientClassName()) { return ( <div data-hook="page-gradient-class-name" className={st( classes.gradientBackground, {}, this.props.gradientClassName, )} style={{ height: backgroundHeight }} /> ); } } _renderTail() { const { PageTail } = this._getNamedChildren(); const dataHook = 'page-tail'; return ( PageTail && ( <div data-hook={dataHook} key={dataHook} className={classes.tail} ref={r => (this.pageHeaderTailRef = r)} > {PageTail} </div> ) ); } _renderContentContainer() { const { footerHeight } = this.state; const { children } = this.props; const childrenObject = getChildrenObject(children); const { PageContent, PageFixedContent } = childrenObject; return ( <PageContext.Provider value={{ stickyStyle: { top: `${ this._getMinimizedHeaderWrapperHeight() + this.state.tailHeight }px`, }, }} > {this._renderContentHorizontalLayout({ className: classes.contentContainer, style: { paddingBottom: footerHeight || '48px', }, horizontalScroll: this.props.horizontalScroll, children: ( <div className={classes.contentFloating}> {PageFixedContent && ( <PageSticky data-hook="page-fixed-content"> {React.cloneElement(PageFixedContent)} </PageSticky> )} {this._safeGetChildren(PageContent)} </div> ), })} </PageContext.Provider> ); } _renderFixedFooter = () => { const { children } = this.props; const childrenObject = getChildrenObject(children); const { FixedFooter: FixedFooterChild, PageContent } = childrenObject; const contentFullScreen = PageContent && PageContent.props.fullScreen; const pageDimensionsStyle = contentFullScreen ? null : this._getPageDimensionsStyle(); if (FixedFooterChild) { return ( <div className={classes.fixedFooter} ref={ref => { this.footerWrapperRef = ref; }} style={pageDimensionsStyle} > {React.cloneElement(FixedFooterChild, {})} </div> ); } }; render() { const { dataHook, className, minWidth, zIndex, height } = this.props; return ( <div data-hook={dataHook} className={st(classes.root, {}, className)} style={{ zIndex, height }} > <div data-hook="page" className={classes.page} style={{ minWidth: minWidth + 2 * parseInt(stVars.pageSidePadding, 10), }} ref={ref => (this.pageRef = ref)} > {this._renderScrollableContainer()} </div> </div> ); } /** * Scrolls the page to a particular set of coordinates * @param {ScrollToOptions} scrollTo { left: number, top: number, behavior: 'smooth' | 'auto' } */ scrollTo(scrollTo) { const scrollContainer = this._getScrollContainer(); scrollContainer.scrollTo(scrollTo); } } const FixedContent = props => props.children; FixedContent.displayName = 'Page.FixedContent'; FixedContent.propTypes = { children: PropTypes.element.isRequired, }; Page.displayName = 'Page'; Page.Header = PageHeader; Page.Section = PageSection; Page.Content = Content; Page.FixedContent = FixedContent; // TODO: deprecate, use Page.Sticky instead Page.Tail = Tail; Page.FixedFooter = FixedFooter; Page.Sticky = PageSticky; const allowedChildren = [ Page.Header, Page.Section, Page.Content, Page.FixedContent, Page.Tail, Page.FixedFooter, ]; Page.propTypes = { /** Applied as data-hook HTML attribute that can be used in the tests */ dataHook: PropTypes.string, /** Background image url of the header background */ backgroundImageUrl: PropTypes.string, /** Sets the max width of the content (Both in header and body) NOT including the page padding */ maxWidth: PropTypes.number, /** Sets the min width of the content (Both in header and body) NOT including the page padding */ minWidth: PropTypes.number, /** Allow the page to scroll horizontally for large width content */ horizontalScroll: PropTypes.bool, /** Sets the height of the page (in px/vh/etc.) */ height: PropTypes.string, /** Sets padding of the sides of the page */ sidePadding: PropTypes.number, /** A css class to be applied to the component's root element */ className: PropTypes.string, /** Header background color class name, allows to add a gradient to the header */ gradientClassName: PropTypes.string, /** Will be called with the Page's scrollable content ref after page mount. * * **Note** - If you need this ref just for listening to scroll events on the scrollable content then use the prop * `scrollProps = {onScrollChanged/onScrollAreaChanged}` instead according to your needs. **/ scrollableContentRef: PropTypes.func, /** Props related to the scrollable content of the page. * * **onScrollAreaChanged** - A Handler for scroll area changes, will be triggered only when the user scrolls to a * different area of the scrollable content, see signature for possible areas * ##### Signature: * `function({area: {y: AreaY, x: AreaX}, target: HTMLElement}) => void` * * `AreaY`: top | middle | bottom | none * * `AreaX`: start | middle | end | none (not implemented yet) * * **onScrollAreaChanged** - A Generic Handler for scroll changes with throttling (100ms) * ##### Signature: * `function({target: HTMLElement}) => void` * */ scrollProps: PropTypes.shape(ScrollableContainerCommonProps), /** Accepts these components as children: `Page.Header`, `Page.Tail`, `Page.Content`, `Page.FixedContent`. Order is insignificant. */ children: PropTypes.arrayOf((children, key) => { const child = children[key]; if (!child) { return; } const allowedDisplayNames = allowedChildren.map(c => c.displayName); const childDisplayName = child.type.displayName; if (!allowedDisplayNames.includes(childDisplayName)) { return new Error( `Page: Invalid Prop children, unknown child ${child.type}`, ); } }).isRequired, /** z-index of the Page */ zIndex: PropTypes.number, }; function getChildrenObject(children) { return React.Children.toArray(children).reduce((acc, child) => { switch (child.type.displayName) { case 'Page.Header': { acc.PageHeader = child; break; } case 'Page.Section': { acc.Section = child; break; } case 'Page.Content': { acc.PageContent = child; break; } case 'Page.FixedContent': { acc.PageFixedContent = child; break; } case 'Page.Tail': { acc.PageTail = child; break; } case 'Page.FixedFooter': { acc.FixedFooter = child; break; } default: { break; } } return acc; }, {}); } export default Page;