diff --git a/src/components/PromoSheet/PromoSheet.scss b/src/components/PromoSheet/PromoSheet.scss new file mode 100644 index 0000000000..1d14d1823a --- /dev/null +++ b/src/components/PromoSheet/PromoSheet.scss @@ -0,0 +1,68 @@ +@use '../variables'; + +$block: '.#{variables.$ns}promo-sheet'; + +#{$block} { + &__content[class] { + width: auto; + padding: var(--yc-promo-sheet-padding); + margin: 0 var(--yc-promo-sheet-margin) var(--yc-promo-sheet-margin); + + color: var(--yc-promo-sheet-foreground); + background: var(--yc-promo-sheet-background); + border-radius: var(--yc-promo-sheet-border-radius); + } + + &__header { + position: relative; + + padding: 0 20px 0 0; + margin: 0 0 var(--yc-promo-sheet-header-margin); + } + + &__title { + margin: 0; + + font-size: var(--yc-text-header-1-font-size); + line-height: var(--yc-text-header-1-line-height); + } + + &__close-button { + position: absolute; + top: calc(12px * -1); + right: calc(12px * -1); + } + + &__message { + margin: 0 0 var(--yc-promo-sheet-message-margin); + + font-size: var(--yc-text-body-3-font-size); + line-height: var(--yc-text-body-3-line-height); + } + + &__image-container { + margin-bottom: var(--yc-promo-sheet-image-margin); + } + + &__image { + display: block; + + width: 100%; + height: auto; + } + + &__action-button { + display: block; + } +} + +.yc-root { + --yc-promo-sheet-margin: 8px; + --yc-promo-sheet-padding: 20px; + --yc-promo-sheet-border-radius: 12px; + --yc-promo-sheet-header-margin: 12px; + --yc-promo-sheet-message-margin: 16px; + --yc-promo-sheet-image-margin: 12px; + --yc-promo-sheet-foreground: var(--yc-color-text-light-primary); + --yc-promo-sheet-background: var(--yc-my-color-brand-normal); +} diff --git a/src/components/PromoSheet/PromoSheet.tsx b/src/components/PromoSheet/PromoSheet.tsx new file mode 100644 index 0000000000..f60520b8e0 --- /dev/null +++ b/src/components/PromoSheet/PromoSheet.tsx @@ -0,0 +1,144 @@ +import type {FC} from 'react'; +import React, {useState, useCallback, useEffect, useMemo} from 'react'; +import {block} from '../utils/cn'; +import {CrossIcon} from '../icons/CrossIcon'; +import type {ButtonProps, SheetProps} from '../'; +import {Button, Icon, Sheet} from '../'; + +import './PromoSheet.scss'; + +const cn = block('promo-sheet'); + +export type PromoSheetProps = { + title: string; + message: string; + actionText: string; + closeText: string; + actionHref?: string; + imageSrc?: string; + className?: string; + contentClassName?: string; + imageContainerClassName?: string; + imageClassName?: string; + onActionClick?: ButtonProps['onClick']; + onClose?: SheetProps['onClose']; +}; + +type ImageSizes = { + width?: number; + height?: number; +}; + +export const PromoSheet: FC = ({ + title, + message, + actionText, + closeText, + actionHref, + imageSrc, + className, + contentClassName, + imageContainerClassName, + imageClassName, + onActionClick, + onClose, +}) => { + const [visible, setVisible] = useState(true); + const [loaded, setLoaded] = useState(!imageSrc); + const [imageSizes, setImageSizes] = useState(); + + const handleActionClick = useCallback>( + (event) => { + setVisible(false); + onActionClick?.(event); + }, + [onActionClick], + ); + + const handleCloseClick = useCallback>(() => { + setVisible(false); + }, []); + + const closeButtonExtraProps = useMemo( + () => ({ + 'aria-label': closeText, + }), + [closeText], + ); + + useEffect(() => { + if (!imageSrc) { + setLoaded(true); + + return; + } + + const image = new Image(); + + image.onload = () => { + setImageSizes({ + width: image.naturalWidth, + height: image.naturalHeight, + }); + setLoaded(true); + image.onload = null; + image.onerror = null; + }; + image.onerror = () => { + setImageSizes(undefined); + setLoaded(true); + image.onload = null; + image.onerror = null; + }; + + image.src = imageSrc; + }, [imageSrc]); + + return ( + +
+

{title}

+ +
+

{message}

+ {imageSrc && ( +
+ +
+ )} +
+ +
+
+ ); +}; diff --git a/src/components/PromoSheet/README.md b/src/components/PromoSheet/README.md new file mode 100644 index 0000000000..970efe91a8 --- /dev/null +++ b/src/components/PromoSheet/README.md @@ -0,0 +1,3 @@ +# PromoSheet + +A component for displaying a promo dialog informing the user about a new feature in the service's mobile application. diff --git a/src/components/PromoSheet/__stories__/PromoSheet.stories.tsx b/src/components/PromoSheet/__stories__/PromoSheet.stories.tsx new file mode 100644 index 0000000000..1b3887469d --- /dev/null +++ b/src/components/PromoSheet/__stories__/PromoSheet.stories.tsx @@ -0,0 +1,27 @@ +import React from 'react'; +import type {Meta, Story} from '@storybook/react/types-6-0'; +import {actions} from '@storybook/addon-actions'; +import type {PromoSheetProps} from '../PromoSheet'; +import {PromoSheet} from '../PromoSheet'; + +export default { + title: 'Components/PromoSheet', + component: PromoSheet, +} as Meta; + +const actionsHandlers = actions('onActionClick', 'onClose'); + +const DefaultTemplate: Story = (args) => { + return ; +}; + +export const Default = DefaultTemplate.bind({}); + +Default.args = { + title: 'Some announcement title', + message: + 'Some announcement message with a lot of text. We want to see how it looks like when there is more than one line of text. Check if everything looks OK with margins.', + actionText: 'Action', + closeText: 'Close', +}; +Default.parameters = {}; diff --git a/src/components/PromoSheet/__tests__/PromoSheet.test.tsx b/src/components/PromoSheet/__tests__/PromoSheet.test.tsx new file mode 100644 index 0000000000..e11e1591d4 --- /dev/null +++ b/src/components/PromoSheet/__tests__/PromoSheet.test.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import {act, render, screen} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import {PromoSheet} from '../PromoSheet'; + +test('Renders base content', () => { + const title = 'Title text'; + const message = 'Message text'; + const actionText = 'Action text'; + const closeText = 'Close text'; + + render( + , + ); + + expect(screen.getByRole('heading')).toHaveTextContent(title); + expect(screen.getByText(message)).toBeInTheDocument(); + expect(screen.getByRole('button', {name: closeText})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: actionText})).toBeInTheDocument(); + + expect(screen.queryByRole('presentation')).not.toBeInTheDocument(); +}); + +test('Has image when imageSrc property is set', () => { + const originalWindowImage = window.Image; + let onLoad = () => {}; + + window.Image = class FakeImage { + naturalWidth = 0; + naturalHeight = 0; + + set onload(fn: () => void) { + onLoad = fn; + } + } as unknown as typeof Image; + + render(); + + window.Image = originalWindowImage; + onLoad(); + + expect(screen.getByRole('presentation')).toBeInTheDocument(); +}); + +test('Call onActionClick and onClose by action button', async () => { + const handleActionClick = jest.fn(); + const handleClose = jest.fn(); + + render( + , + ); + + const actionButton = screen.getByRole('button', {name: 'Action'}); + const user = userEvent.setup(); + + await act(() => user.click(actionButton)); + + expect(handleActionClick).toBeCalled(); +}); diff --git a/src/components/PromoSheet/index.ts b/src/components/PromoSheet/index.ts new file mode 100644 index 0000000000..915a28807b --- /dev/null +++ b/src/components/PromoSheet/index.ts @@ -0,0 +1 @@ +export * from './PromoSheet'; diff --git a/src/components/Sheet/README.md b/src/components/Sheet/README.md new file mode 100644 index 0000000000..ed6f5cec8e --- /dev/null +++ b/src/components/Sheet/README.md @@ -0,0 +1,17 @@ +## MobileModal + +Sheet component for mobile devices + +### PropTypes + +| Property | Type | Required | Default | Description | +| :----------------------- | :--------- | :------: | :---------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| visible | `boolean` | ✓ | | Show/hide sheet | +| allowHideOnContentScroll | `boolean` | | `true` | Enable the behavior in which you can close the sheet window with a swipe down if the content is scrolled to its top (`contentNode.scrollTop === 0`) or has no scroll at all | +| noTopBar | `boolean` | | | Hide top bar with resize handle | +| id | `string` | | `modal` | ID of the sheet, used as hash in URL. It's important to specify different `id` values if there can be more than one sheet on the page | +| title | `string` | | `undefined` | Title of the sheet window | +| className | `string` | | `undefined` | Class name for the sheet window | +| contentClassName | `string` | | `undefined` | Class name for the sheet content | +| swipeAreaClassName | `string` | | `undefined` | Class name for the swipe area | +| onClose | `function` | | `undefined` | Function called when the sheet is closed (when `visible` sets to `false`) | diff --git a/src/components/Sheet/Sheet.scss b/src/components/Sheet/Sheet.scss new file mode 100644 index 0000000000..6f1f7871dd --- /dev/null +++ b/src/components/Sheet/Sheet.scss @@ -0,0 +1,102 @@ +@use '../variables'; + +$block: '.#{variables.$ns}sheet'; + +#{$block} { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 100000; + + $SHEET_TOP_HEIGHT: 20px; + + &__veil { + position: absolute; + top: 0; + left: 0; + height: 100%; + width: 100%; + background-color: var(--yc-color-sfx-veil); + opacity: 0; + will-change: opacity; + + &_with-transition { + transition: opacity var(--yc-sheet-transition-duration) ease; + } + } + + &__sheet { + position: absolute; + top: 100%; + left: 0; + width: 100%; + max-height: 90%; + will-change: transform; + + &_with-transition { + transition: transform var(--yc-sheet-transition-duration) ease; + } + } + + &__sheet-swipe-area { + position: absolute; + top: -20px; + left: 0; + width: 100%; + height: 40px; + z-index: 1; + } + + &__sheet-top { + position: relative; + height: $SHEET_TOP_HEIGHT; + border-top-left-radius: 20px; + border-top-right-radius: 20px; + background-color: var(--yc-color-base-float); + } + + &__sheet-top-resizer { + position: absolute; + top: 50%; + left: 50%; + transform: translateX(-50%) translateY(-50%); + width: 40px; + height: 4px; + border-radius: 4px; + background-color: var(--yc-color-line-generic); + } + + &__sheet-content { + box-sizing: border-box; + width: 100%; + padding: var(--yc-sheet-content-paddings); + max-height: calc(90% - #{$SHEET_TOP_HEIGHT}); + overflow-x: hidden; + overflow-y: auto; + overscroll-behavior-y: contain; + background-color: var(--yc-color-base-float); + + transition: height var(--yc-sheet-transition-duration) ease; + + &_without-scroll { + overflow: hidden; + } + } + + &__sheet-content-title { + padding-bottom: 8px; + font-size: var(--yc-text-body-2-font-size); + line-height: 28px; + text-align: center; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } +} + +.yc-root { + --yc-sheet-content-paddings: 0 10px; + --yc-sheet-transition-duration: 0.3s; +} diff --git a/src/components/Sheet/Sheet.tsx b/src/components/Sheet/Sheet.tsx new file mode 100644 index 0000000000..54359f715a --- /dev/null +++ b/src/components/Sheet/Sheet.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import {SheetContentContainer} from './SheetContent'; +import {sheetBlock} from './constants'; + +import './Sheet.scss'; + +export interface SheetProps { + onClose?: () => void; + /** Show/hide sheet */ + visible: boolean; + /** ID of the sheet, used as hash in URL. It's important to specify different `id` values if there can be more than one sheet on the page */ + id?: string; + /** Title of the sheet window */ + title?: string; + /** Class name for the sheet window */ + className?: string; + /** Class name for the sheet content */ + contentClassName?: string; + /** Class name for the swipe area */ + swipeAreaClassName?: string; + /** Enable the behavior in which you can close the sheet window with a swipe down if the content is scrolled to its top (`contentNode.scrollTop === 0`) or has no scroll at all */ + allowHideOnContentScroll?: boolean; + /** Hide top bar with resize handle */ + noTopBar?: boolean; +} + +interface SheetState { + visible: boolean; +} + +export class Sheet extends React.Component { + private static bodyScrollLocksCount = 0; + private static bodyInitialOverflow: string | undefined = undefined; + + static lockBodyScroll() { + if (++Sheet.bodyScrollLocksCount === 1) { + Sheet.bodyInitialOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + } + } + + static restoreBodyScroll() { + if (Sheet.bodyScrollLocksCount === 0) { + return; + } + + if (--Sheet.bodyScrollLocksCount === 0) { + document.body.style.overflow = Sheet.bodyInitialOverflow || ''; + Sheet.bodyInitialOverflow = undefined; + } + } + + bodyScrollLocked = false; + + state: SheetState = { + visible: false, + }; + + componentDidMount() { + if (this.props.visible) { + this.showSheet(); + } + } + + componentDidUpdate(prevProps: SheetProps) { + if (!prevProps.visible && this.props.visible) { + this.showSheet(); + } + } + + componentWillUnmount() { + this.restoreBodyScroll(); + } + + render() { + if (!this.state.visible) { + return null; + } + + return ReactDOM.createPortal(this.renderSheet(), document.body); + } + + restoreBodyScroll() { + if (!this.bodyScrollLocked) { + return; + } + + Sheet.restoreBodyScroll(); + this.bodyScrollLocked = false; + } + + lockBodyScroll() { + Sheet.lockBodyScroll(); + this.bodyScrollLocked = true; + } + + private renderSheet() { + const { + id, + children, + className, + contentClassName, + swipeAreaClassName, + title, + visible, + allowHideOnContentScroll, + noTopBar, + } = this.props; + + return ( +
+ +
+ ); + } + + private showSheet = () => { + this.lockBodyScroll(); + this.setState({visible: true}); + }; + + private hideSheet = () => { + this.restoreBodyScroll(); + + if (this.props.onClose) { + this.props.onClose(); + } + + this.setState({visible: false}); + }; +} diff --git a/src/components/Sheet/SheetContent.tsx b/src/components/Sheet/SheetContent.tsx new file mode 100644 index 0000000000..2f17f1d322 --- /dev/null +++ b/src/components/Sheet/SheetContent.tsx @@ -0,0 +1,482 @@ +import React from 'react'; +import {MobileContextProps, Platform, withMobile, History, Location} from '../'; +import {VelocityTracker} from './utils'; +import {sheetBlock} from './constants'; + +import './Sheet.scss'; + +const DEFAULT_TRANSITION_DURATION = '0.3s'; +const HIDE_THRESHOLD = 50; +const ACCELERATION_Y_MAX = 0.08; +const ACCELERATION_Y_MIN = -0.02; + +let hashHistory: string[] = []; + +type Status = 'showing' | 'hiding'; + +interface SheetContentBaseProps { + hideSheet: () => void; + content: React.ReactNode; + visible: boolean; + id?: string; + title?: string; + contentClassName?: string; + swipeAreaClassName?: string; + noTopBar?: boolean; +} + +interface SheetContentDefaultProps { + id: string; + allowHideOnContentScroll: boolean; +} + +type SheetContentProps = SheetContentBaseProps & Partial; + +interface RouteComponentProps { + history: History; + location: Location; +} + +type SheetContentInnerProps = SheetContentProps & + RouteComponentProps & + Omit; + +interface SheetContentState { + startScrollTop: number; + startY: number; + deltaY: number; + prevInnerContentHeight: number; + swipeAreaTouched: boolean; + contentTouched: boolean; + veilTouched: boolean; + isAnimating: boolean; + inWindowResizeScope: boolean; +} + +class SheetContent extends React.Component { + static defaultProps: SheetContentDefaultProps = { + id: 'sheet', + allowHideOnContentScroll: true, + }; + + veilRef = React.createRef(); + sheetRef = React.createRef(); + sheetTopRef = React.createRef(); + sheetContentRef = React.createRef(); + sheetInnerContentRef = React.createRef(); + velocityTracker = new VelocityTracker(); + observer: MutationObserver | null = null; + transitionDuration = DEFAULT_TRANSITION_DURATION; + + state: SheetContentState = { + startScrollTop: 0, + startY: 0, + deltaY: 0, + prevInnerContentHeight: 0, + swipeAreaTouched: false, + contentTouched: false, + veilTouched: false, + isAnimating: false, + inWindowResizeScope: false, + }; + + componentDidMount() { + this.addListeners(); + this.show(); + this.setInitialStyles(); + this.setState({prevInnerContentHeight: this.innerContentHeight}); + } + + componentDidUpdate(prevProps: SheetContentInnerProps) { + const {visible, location} = this.props; + + if (!prevProps.visible && visible) { + this.show(); + } + + if ((prevProps.visible && !visible) || this.shouldClose(prevProps)) { + this.hide(); + } + + if (prevProps.location.pathname !== location.pathname) { + hashHistory = []; + } + } + + componentWillUnmount() { + this.removeListeners(); + } + + render() { + const {content, contentClassName, swipeAreaClassName, noTopBar, title} = this.props; + + const { + deltaY, + swipeAreaTouched, + contentTouched, + veilTouched, + isAnimating, + inWindowResizeScope, + } = this.state; + + const veilTransitionMod = { + 'with-transition': !deltaY || veilTouched, + }; + + const sheetTransitionMod = { + 'with-transition': !inWindowResizeScope && veilTransitionMod['with-transition'], + }; + + const contentMod = { + 'without-scroll': (deltaY > 0 && contentTouched) || swipeAreaTouched, + }; + + return ( + +
+
+ {!noTopBar && ( +
+
+
+ )} + {/* TODO: extract to external component SwipeArea */} +
+ {/* TODO: extract to external component ContentArea */} +
+ {title &&
{title}
} +
{content}
+
+
+ + ); + } + + private get veilOpacity() { + return this.veilRef.current?.style.opacity || 0; + } + + private get sheetTopHeight() { + return this.sheetTopRef.current?.getBoundingClientRect().height || 0; + } + + private get sheetHeight() { + return this.sheetRef.current?.getBoundingClientRect().height || 0; + } + + private get innerContentHeight() { + return this.sheetInnerContentRef.current?.getBoundingClientRect().height || 0; + } + + private get sheetScrollTop() { + return this.sheetContentRef.current?.scrollTop || 0; + } + + private setInitialStyles() { + if (this.sheetContentRef.current && this.sheetInnerContentRef.current) { + this.transitionDuration = getComputedStyle( + this.sheetContentRef.current, + ).getPropertyValue('--yc-sheet-transition-duration'); + + const initialHeight = this.sheetHeight - this.sheetTopHeight; + this.sheetContentRef.current.style.height = `${initialHeight}px`; + } + } + + private setStyles = ({status, deltaHeight = 0}: {status: Status; deltaHeight?: number}) => { + if (!this.sheetRef.current || !this.veilRef.current) { + return; + } + + const visibleHeight = this.sheetHeight - deltaHeight; + const translate = + status === 'showing' + ? `translate3d(0, -${visibleHeight}px, 0)` + : 'translate3d(0, 0, 0)'; + let opacity = 0; + + if (status === 'showing') { + opacity = deltaHeight === 0 ? 1 : visibleHeight / this.sheetHeight; + } + + this.veilRef.current.style.opacity = String(opacity); + + this.sheetRef.current.style.transform = translate; + }; + + private show = () => { + this.setState({isAnimating: true}, () => { + this.setStyles({status: 'showing'}); + this.setHash(); + }); + }; + + private hide = () => { + this.setState({isAnimating: true}, () => { + this.setStyles({status: 'hiding'}); + this.removeHash(); + }); + }; + + private onSwipeAreaTouchStart = (e: React.TouchEvent) => { + this.velocityTracker.clear(); + + this.setState({ + startY: e.nativeEvent.touches[0].clientY, + swipeAreaTouched: true, + }); + }; + + private onContentTouchStart = (e: React.TouchEvent) => { + if (!this.props.allowHideOnContentScroll || this.state.swipeAreaTouched) { + return; + } + + this.velocityTracker.clear(); + + this.setState({ + startY: e.nativeEvent.touches[0].clientY, + startScrollTop: this.sheetScrollTop, + contentTouched: true, + }); + }; + + private onSwipeAriaTouchMove = (e: React.TouchEvent) => { + const delta = e.nativeEvent.touches[0].clientY - this.state.startY; + + this.velocityTracker.addMovement({ + x: e.nativeEvent.touches[0].clientX, + y: e.nativeEvent.touches[0].clientY, + }); + + this.setState({deltaY: delta}); + + if (delta <= 0) { + return; + } + + this.setStyles({status: 'showing', deltaHeight: delta}); + }; + + private onContentTouchMove = (e: React.TouchEvent) => { + if (!this.props.allowHideOnContentScroll) { + return; + } + + const {startScrollTop, swipeAreaTouched} = this.state; + + if ( + swipeAreaTouched || + this.sheetScrollTop > 0 || + (startScrollTop > 0 && startScrollTop !== this.sheetScrollTop) + ) { + return; + } + + const delta = e.nativeEvent.touches[0].clientY - this.state.startY; + + this.velocityTracker.addMovement({ + x: e.nativeEvent.touches[0].clientX, + y: e.nativeEvent.touches[0].clientY, + }); + + this.setState({deltaY: delta}); + + if (delta <= 0) { + return; + } + + this.setStyles({status: 'showing', deltaHeight: delta}); + }; + + private onTouchEndAction = (deltaY: number) => { + const accelerationY = this.velocityTracker.getYAcceleration(); + + if (this.sheetHeight <= deltaY) { + this.props.hideSheet(); + } else if ( + (deltaY > HIDE_THRESHOLD && + accelerationY <= ACCELERATION_Y_MAX && + accelerationY >= ACCELERATION_Y_MIN) || + accelerationY > ACCELERATION_Y_MAX + ) { + this.hide(); + } else if (deltaY > 0) { + this.show(); + } + }; + + private onSwipeAriaTouchEnd = () => { + const {deltaY} = this.state; + + this.onTouchEndAction(deltaY); + + this.setState({ + startY: 0, + deltaY: 0, + swipeAreaTouched: false, + }); + }; + + private onContentTouchEnd = () => { + const {deltaY, swipeAreaTouched} = this.state; + + if (!this.props.allowHideOnContentScroll || swipeAreaTouched) { + return; + } + + this.onTouchEndAction(deltaY); + + this.setState({ + startY: 0, + deltaY: 0, + contentTouched: false, + }); + }; + + private onVeilClick = () => { + this.setState({veilTouched: true}); + this.hide(); + }; + + private onVeilTransitionEnd = () => { + this.setState({isAnimating: false}); + + if (this.veilOpacity === '0') { + this.props.hideSheet(); + } + }; + + private onContentTransitionEnd = (e: React.TransitionEvent) => { + if (e.propertyName === 'height') { + if (this.sheetContentRef.current) { + this.sheetContentRef.current.style.transition = 'none'; + } + } + }; + + private onResizeWindow = () => { + this.setState({inWindowResizeScope: true}); + + this.onResize(); + + setTimeout(() => this.setState({inWindowResizeScope: false}), 0); + }; + + private onResize = () => { + const heightChanged = this.state.prevInnerContentHeight !== this.innerContentHeight; + + if (!this.sheetRef.current || !this.sheetContentRef.current || !heightChanged) { + return; + } + + this.sheetContentRef.current.style.transition = + this.state.prevInnerContentHeight > this.innerContentHeight + ? `height 0s ease ${this.transitionDuration}` + : 'none'; + + this.setState({prevInnerContentHeight: this.innerContentHeight}); + + this.sheetContentRef.current.style.height = `${this.innerContentHeight}px`; + this.sheetRef.current.style.transform = `translate3d(0, -${ + this.innerContentHeight + this.sheetTopHeight + }px, 0)`; + }; + + private addListeners() { + window.addEventListener('resize', this.onResizeWindow); + + if (this.sheetRef.current) { + const config = {subtree: true, childList: true}; + this.observer = new MutationObserver(this.onResize); + this.observer.observe(this.sheetRef.current, config); + } + } + + private removeListeners() { + window.removeEventListener('resize', this.onResizeWindow); + + if (this.observer) { + this.observer.disconnect(); + } + } + + private setHash() { + const {id, platform, location, history} = this.props; + + if (platform === Platform.BROWSER) { + return; + } + + const newLocation = {...location, hash: id}; + + switch (platform) { + case Platform.IOS: + if (location.hash) { + hashHistory.push(location.hash); + } + history.replace(newLocation); + break; + case Platform.ANDROID: + history.push(newLocation); + break; + } + } + + private removeHash() { + const {id, platform, location, history} = this.props; + + if (platform === Platform.BROWSER || location.hash !== `#${id}`) { + return; + } + + switch (platform) { + case Platform.IOS: + history.replace({...location, hash: hashHistory.pop() ?? ''}); + break; + case Platform.ANDROID: + history.goBack(); + break; + } + } + + private shouldClose(prevProps: SheetContentInnerProps) { + const {id, platform, location, history} = this.props; + + return ( + platform !== Platform.BROWSER && + history.action === 'POP' && + prevProps.location.hash !== location.hash && + location.hash !== `#${id}` + ); + } +} + +function withRouterWrapper(Component: React.ComponentType) { + const ComponentWithRouter = (props: MobileContextProps & SheetContentProps) => { + const {useHistory, useLocation, ...remainingProps} = props; + return ; + }; + const componentName = Component.displayName || Component.name || 'Component'; + + ComponentWithRouter.displayName = `withRouterWrapper(${componentName})`; + return ComponentWithRouter; +} +export const SheetContentContainer = withMobile(withRouterWrapper(SheetContent)); diff --git a/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.scss b/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.scss new file mode 100644 index 0000000000..80fd8425d4 --- /dev/null +++ b/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.scss @@ -0,0 +1,34 @@ +.sheet-stories-default-showcase { + display: flex; + flex-direction: column; + justify-content: center; + + &__show-btn { + position: sticky; + top: 0; + display: flex; + justify-content: center; + margin-bottom: 20px; + background-color: var(--yc-color-base-background); + z-index: 1; + } + + &__checkbox { + display: flex; + justify-content: center; + align-items: center; + height: 40px; + } + + &__extra-content { + word-wrap: break-word; + } + + &__content-item { + margin-bottom: 15px; + + &:last-child { + margin-bottom: 25px; + } + } +} diff --git a/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.stories.tsx b/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.stories.tsx new file mode 100644 index 0000000000..b3ca3601cd --- /dev/null +++ b/src/components/Sheet/__stories__/DefaultShowcase/DefaultShowcase.stories.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import block from 'bem-cn-lite'; +import {Story} from '@storybook/react'; +import {Button, Checkbox} from '../../../'; +import {Sheet, SheetProps} from '../../Sheet'; + +import './DefaultShowcase.scss'; + +const b = block('sheet-stories-default-showcase'); + +const getRandomText = (length: number) => { + let result = ''; + + const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + + for (let i = 0; i < length; i++) { + result += possible.charAt(Math.floor(Math.random() * possible.length)); + } + + return result; +}; + +const EXTRA_OUTER_CONTENT = getRandomText(8000); +const EXTRA_INNER_CONTENT = getRandomText(1000); + +export const Showcase: Story = (args: SheetProps) => { + const [visible, setVisible] = React.useState(false); + const [withExtraOuterContent, setWithExtraOuterContent] = React.useState(false); + const [withExtraInnerContent, setWithExtraInnerContent] = React.useState(false); + + return ( +
+
+ +
+
+ setWithExtraOuterContent(!withExtraOuterContent)} + checked={withExtraOuterContent} + /> +
+ {withExtraOuterContent && ( +
{EXTRA_OUTER_CONTENT}
+ )} + setVisible(false)}> +
+ setWithExtraInnerContent(!withExtraInnerContent)} + checked={withExtraInnerContent} + /> +
+ {withExtraInnerContent && ( +
+ {EXTRA_INNER_CONTENT} +
+ )} + +
+
+ ); +}; diff --git a/src/components/Sheet/__stories__/Sheet.stories.tsx b/src/components/Sheet/__stories__/Sheet.stories.tsx new file mode 100644 index 0000000000..6c514014c6 --- /dev/null +++ b/src/components/Sheet/__stories__/Sheet.stories.tsx @@ -0,0 +1,17 @@ +import {Meta} from '@storybook/react'; +import {Sheet} from '../Sheet'; +import {Showcase} from './DefaultShowcase/DefaultShowcase.stories'; +import {WithMenuShowcase} from './WithMenuShowcase/WithMenuShowcase.stories'; + +export default { + title: 'Components/Sheet', + component: Sheet, +} as Meta; + +export const Default = Showcase.bind({}); + +Default.args = { + allowHideOnContentScroll: false, +}; + +export const WithMenu = WithMenuShowcase.bind({}); diff --git a/src/components/Sheet/__stories__/WithMenuShowcase/WithMenuShowcase.scss b/src/components/Sheet/__stories__/WithMenuShowcase/WithMenuShowcase.scss new file mode 100644 index 0000000000..0d3a70bc07 --- /dev/null +++ b/src/components/Sheet/__stories__/WithMenuShowcase/WithMenuShowcase.scss @@ -0,0 +1,8 @@ +.sheet-stories-with-menu-showcase { + display: flex; + justify-content: center; + + &__show-btn { + width: max-content; + } +} diff --git a/src/components/Sheet/__stories__/WithMenuShowcase/WithMenuShowcase.stories.tsx b/src/components/Sheet/__stories__/WithMenuShowcase/WithMenuShowcase.stories.tsx new file mode 100644 index 0000000000..75fffd95df --- /dev/null +++ b/src/components/Sheet/__stories__/WithMenuShowcase/WithMenuShowcase.stories.tsx @@ -0,0 +1,35 @@ +import React, {useState} from 'react'; +import block from 'bem-cn-lite'; +import {Story} from '@storybook/react'; +import {Button, Menu} from '../../../'; +import {Sheet, SheetProps} from '../../Sheet'; + +import './WithMenuShowcase.scss'; + +const b = block('sheet-stories-with-menu-showcase'); + +export const WithMenuShowcase: Story = (args: SheetProps) => { + const [visible, setVisible] = useState(false); + + return ( +
+ + setVisible(false)}> + + + menu item 1.1 + menu item 1.2 + menu item 1.3 + + + menu item 2.1 + menu item 2.2 + menu item 2.3 + + + +
+ ); +}; diff --git a/src/components/Sheet/__tests__/Sheet.test.tsx b/src/components/Sheet/__tests__/Sheet.test.tsx new file mode 100644 index 0000000000..390ff2598c --- /dev/null +++ b/src/components/Sheet/__tests__/Sheet.test.tsx @@ -0,0 +1,26 @@ +import {render, screen} from '@testing-library/react'; +import React from 'react'; +import {Sheet} from '../Sheet'; +import {sheetBlock} from '../constants'; + +test('Renders content when visible', () => { + const sheetContent = 'Sheet content'; + render({sheetContent}); + + expect(screen.getByText(sheetContent)).toBeInTheDocument(); +}); + +test('Do not renders content when invisible', () => { + const sheetContent = 'Sheet content'; + render(${sheetContent}); + + expect(screen.queryByText(sheetContent)).not.toBeInTheDocument(); +}); + +test('Do not renders top bar when noTopBar property is set', () => { + const {container} = render(); + + // Element is accessible only by selector + // eslint-disable-next-line testing-library/no-container, testing-library/no-node-access + expect(container.querySelector(sheetBlock('sheet-top'))).not.toBeInTheDocument(); +}); diff --git a/src/components/Sheet/constants.ts b/src/components/Sheet/constants.ts new file mode 100644 index 0000000000..e4a0f7909e --- /dev/null +++ b/src/components/Sheet/constants.ts @@ -0,0 +1,3 @@ +import {block} from '../utils/cn'; + +export const sheetBlock = block('sheet'); diff --git a/src/components/Sheet/index.ts b/src/components/Sheet/index.ts new file mode 100644 index 0000000000..1c354cae55 --- /dev/null +++ b/src/components/Sheet/index.ts @@ -0,0 +1 @@ +export * from './Sheet'; diff --git a/src/components/Sheet/utils.ts b/src/components/Sheet/utils.ts new file mode 100644 index 0000000000..cf10f0212b --- /dev/null +++ b/src/components/Sheet/utils.ts @@ -0,0 +1,41 @@ +class Point { + x: number; + y: number; + timeStamp: number; + + constructor(x: number, y: number) { + this.x = x; + this.y = y; + this.timeStamp = Date.now(); + } +} + +export class VelocityTracker { + pointsLen: number; + points: Point[] = []; + + constructor(len = 5) { + this.pointsLen = len; + this.clear(); + } + + clear() { + this.points = new Array(this.pointsLen); + } + + addMovement({x, y}: {x: number; y: number}) { + this.points.pop(); + this.points.unshift(new Point(x, y)); + } + + getYAcceleration(lastPointCount = 1) { + const endPoint = this.points[0]; + const startPoint = this.points[lastPointCount]; + + if (!endPoint || !startPoint) { + return 0; + } + + return (endPoint.y - startPoint.y) / Math.pow(endPoint.timeStamp - startPoint.timeStamp, 2); + } +} diff --git a/src/components/index.ts b/src/components/index.ts index c339574fcb..ae82a149ff 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -24,11 +24,13 @@ export * from './Popover'; export * from './Popup'; export * from './Portal'; export * from './Progress'; +export * from './PromoSheet'; export * from './Radio'; export * from './RadioButton'; export * from './RadioGroup'; export * from './Select'; export * from './ShareTooltip'; +export * from './Sheet'; export * from './Skeleton'; export * from './Spin'; export * from './StoreBadge';