diff --git a/src/components/Frame/Frame.tsx b/src/components/Frame/Frame.tsx index 0ebd6fac8eb..f2e1fb88e4a 100644 --- a/src/components/Frame/Frame.tsx +++ b/src/components/Frame/Frame.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import {MobileCancelMajorMonotone} from '@shopify/polaris-icons'; import {classNames} from '@shopify/react-utilities/styles'; import {CSSTransition} from 'react-transition-group'; +import {noop} from 'babel-types'; import {navigationBarCollapsed} from '../../utilities/breakpoints'; import Button from '../Button'; import Icon from '../Icon'; @@ -17,8 +18,9 @@ import { FrameContext, frameContextTypes, ToastProps, + SheetProps, } from './types'; -import {ToastManager, Loading, ContextualSaveBar} from './components'; +import {ToastManager, Loading, ContextualSaveBar, Sheet} from './components'; import styles from './Frame.scss'; @@ -44,6 +46,7 @@ export interface State { loadingStack: number; toastMessages: (ToastProps & {id: string})[]; showContextualSaveBar: boolean; + sheet: SheetProps; } export const GLOBAL_RIBBON_CUSTOM_PROPERTY = '--global-ribbon-height'; @@ -64,6 +67,7 @@ export class Frame extends React.PureComponent { toastMessages: [], mobileView: isMobileView(), showContextualSaveBar: false, + sheet: initializeSheet(), }; private contextualSaveBar: ContextualSaveBarProps | null; @@ -104,6 +108,7 @@ export class Frame extends React.PureComponent { toastMessages, showContextualSaveBar, mobileView, + sheet, } = this.state; const { children, @@ -240,6 +245,8 @@ export class Frame extends React.PureComponent { /> ) : null; + const sheetMarkup = ; + return (
{ {globalRibbonMarkup} + {sheetMarkup}
); } + private showSheet = (sheetProps: SheetProps) => { + // TODO: if current sheet is open, should hide sheet first + this.setState({sheet: sheetProps}); + }; + + private hideSheet = () => { + this.setState({sheet: initializeSheet()}); + }; + private setGlobalRibbonHeight = () => { const {globalRibbonContainer} = this; if (globalRibbonContainer) { @@ -404,4 +421,12 @@ function isMobileView() { return navigationBarCollapsed().matches; } +function initializeSheet(): SheetProps { + return { + open: false, + children: null, + onClose: noop, + }; +} + export default withAppProvider()(Frame); diff --git a/src/components/Frame/components/Sheet/README.md b/src/components/Frame/components/Sheet/README.md new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/components/Frame/components/Sheet/Sheet.scss b/src/components/Frame/components/Sheet/Sheet.scss new file mode 100644 index 00000000000..b8a848080a6 --- /dev/null +++ b/src/components/Frame/components/Sheet/Sheet.scss @@ -0,0 +1,71 @@ +$sheet-desktop-width: rem(380px); + +.Sheet { + position: fixed; + bottom: 0; + width: 100vw; + height: 100%; + background: color('white'); + box-shadow: shadow('layer'); + + &.desktop { + right: 0; + width: $sheet-desktop-width; + } + + &:focus { + outline: 0; + } +} + +.Container { + position: fixed; + z-index: z-index('modal', $fixed-element-stacking-order); + top: 0; + right: 0; + bottom: 0; + left: 0; + + &.desktop { + left: auto; + width: $sheet-desktop-width; + } +} + +// Bottom +.Bottom { + will-change: transform; + transition: transform duration('slow') easing('base'); + transform-origin: bottom; +} +.enterBottom { + transform: translateY(100%); +} +.enterBottomActive { + transform: translateY(0%); +} +.exitBottom { + transform: translateY(0%); +} +.exitBottomActive { + transform: translateY(100%); +} + +// Right +.Right { + will-change: transform; + transition: transform duration('slow') easing('base'); + transform-origin: right; +} +.enterRight { + transform: translateX(100%); +} +.enterRightActive { + transform: translateX(0%); +} +.exitRight { + transform: translateX(0%); +} +.exitRightActive { + transform: translateX(100%); +} diff --git a/src/components/Frame/components/Sheet/Sheet.tsx b/src/components/Frame/components/Sheet/Sheet.tsx new file mode 100644 index 00000000000..ff5ba67db72 --- /dev/null +++ b/src/components/Frame/components/Sheet/Sheet.tsx @@ -0,0 +1,110 @@ +import * as React from 'react'; + +import {CSSTransition} from 'react-transition-group'; +import debounce from 'lodash/debounce'; +import {classNames} from '@shopify/react-utilities/styles'; + +import {navigationBarCollapsed} from '../../../../utilities/breakpoints'; +import {Key} from '../../../../types'; +import {layer, overlay, Duration} from '../../../shared'; + +import TrapFocus from '../../../TrapFocus'; +import Portal from '../../../Portal'; +import KeypressListener from '../../../KeypressListener'; +import EventListener from '../../../EventListener'; + +import {SheetProps as Props} from '../../types'; + +import styles from './Sheet.scss'; + +export const BOTTOM_CLASS_NAMES = { + enter: classNames(styles.Bottom, styles.enterBottom), + enterActive: classNames(styles.Bottom, styles.enterBottomActive), + exit: classNames(styles.Bottom, styles.exitBottom), + exitActive: classNames(styles.Bottom, styles.exitBottomActive), +}; + +export const RIGHT_CLASS_NAMES = { + enter: classNames(styles.Right, styles.enterRight), + enterActive: classNames(styles.Right, styles.enterRightActive), + exit: classNames(styles.Right, styles.exitRight), + exitActive: classNames(styles.Right, styles.exitRightActive), +}; + +export interface State { + mobile: boolean; +} + +export default class Sheet extends React.PureComponent { + state: State = { + mobile: false, + }; + + private handleResize = debounce( + () => { + const {mobile} = this.state; + if (mobile !== isMobile()) { + this.setState({mobile: !mobile}); + } + }, + 40, + {leading: true, trailing: true, maxWait: 40}, + ); + + componentDidMount() { + const {mobile} = this.state; + if (mobile !== isMobile()) { + this.setState({mobile: !mobile}); + } + } + + render() { + const {children, open, onClose} = this.props; + const {mobile} = this.state; + + const sheetClassName = classNames(styles.Sheet, !mobile && styles.desktop); + + const containerClassName = classNames( + styles.Container, + !mobile && styles.desktop, + ); + + function Container() { + return ( +
+ +
+ {children} +
+
+
+ ); + } + + const sharedTransitionProps = { + timeout: Duration.Slow, + in: open, + mountOnEnter: true, + unmountOnExit: true, + }; + + const finalTransitionProps = { + classNames: mobile ? BOTTOM_CLASS_NAMES : RIGHT_CLASS_NAMES, + ...sharedTransitionProps, + }; + + return ( + + + + + + + + ); + } +} + +export function isMobile(): boolean { + return navigationBarCollapsed().matches; +} diff --git a/src/components/Frame/components/Sheet/index.ts b/src/components/Frame/components/Sheet/index.ts new file mode 100644 index 00000000000..58c6c5afddb --- /dev/null +++ b/src/components/Frame/components/Sheet/index.ts @@ -0,0 +1,4 @@ +import Sheet from './Sheet'; + +export {isMobile} from './Sheet'; +export default Sheet; diff --git a/src/components/Frame/components/Sheet/tests/Sheet.test.tsx b/src/components/Frame/components/Sheet/tests/Sheet.test.tsx new file mode 100644 index 00000000000..eb0d85eadda --- /dev/null +++ b/src/components/Frame/components/Sheet/tests/Sheet.test.tsx @@ -0,0 +1,70 @@ +import * as React from 'react'; + +import {CSSTransition} from 'react-transition-group'; +import {noop} from '@shopify/javascript-utilities/other'; +import {matchMedia} from '@shopify/jest-dom-mocks'; +import {mountWithAppProvider} from 'test-utilities'; + +import Sheet, {isMobile, BOTTOM_CLASS_NAMES, RIGHT_CLASS_NAMES} from '../Sheet'; + +describe('', () => { + beforeEach(() => { + matchMedia.mock(); + }); + + afterEach(() => { + matchMedia.restore(); + }); + + const mockProps = { + open: true, + onClose: noop, + }; + + it('renders its children', () => { + const children =
Content
; + + const sheet = mountWithAppProvider( + {children}, + ); + + expect(sheet.find(children)).not.toBeNull(); + }); + + it('renders a css transition component with bottom class names at mobile sizes', () => { + matchMedia.setMedia(() => ({matches: true})); + + const sheet = mountWithAppProvider( + +
Content
+
, + ); + + expect(sheet.find(CSSTransition).props().classNames).toEqual( + BOTTOM_CLASS_NAMES, + ); + }); + + it('renders a css transition component with right class names at desktop sizes', () => { + const sheet = mountWithAppProvider( + +
Content
+
, + ); + + expect(sheet.find(CSSTransition).props().classNames).toEqual( + RIGHT_CLASS_NAMES, + ); + }); + + describe('isMobile', () => { + it('returns false by default', () => { + expect(isMobile()).toBe(false); + }); + + it('returns true at mobile sizes', () => { + matchMedia.setMedia(() => ({matches: true})); + expect(isMobile()).toBe(true); + }); + }); +}); diff --git a/src/components/Frame/components/index.ts b/src/components/Frame/components/index.ts index e883cd4ee92..f31ff6b8019 100644 --- a/src/components/Frame/components/index.ts +++ b/src/components/Frame/components/index.ts @@ -9,3 +9,4 @@ export { } from './ToastManager'; export {default as Loading, Props as LoadingProps} from './Loading'; export {default as ContextualSaveBar} from './ContextualSaveBar'; +export {default as Sheet, isMobile as sheetIsMobile} from './Sheet'; diff --git a/src/components/Frame/types.ts b/src/components/Frame/types.ts index 87ac1d8982c..7a3312225ed 100644 --- a/src/components/Frame/types.ts +++ b/src/components/Frame/types.ts @@ -4,6 +4,8 @@ import {Action} from '../../types'; export interface FrameManager { showToast(toast: {id: string} & ToastProps): void; hideToast(toast: {id: string}): void; + showSheet(sheetProps: SheetProps): void; + hideSheet(): void; setContextualSaveBar(props: ContextualSaveBarProps): void; removeContextualSaveBar(): void; startLoading(): void; @@ -67,3 +69,9 @@ export interface ToastProps { /** Adds an action next to the message (stand-alone app use only) */ action?: Action; } + +export interface SheetProps { + open: boolean; + children: React.ReactNode; + onClose(): void; +}