parent
e48b890fd7
commit
c4403ea089
@ -0,0 +1,9 @@ |
||||
import styled from 'styled-components/macro' |
||||
|
||||
export const Overlay = styled.div` |
||||
position: fixed; |
||||
inset: 0; |
||||
opacity: 0.6; |
||||
background-color: ${({ theme }) => theme.colors.black}; |
||||
z-index: 9999; |
||||
` |
||||
@ -1,3 +1,6 @@ |
||||
export enum KEYBOARD_KEYS { |
||||
ArrowLeft = 'ArrowLeft', |
||||
ArrowRight = 'ArrowRight', |
||||
Enter = 'Enter', |
||||
Esc = 'Escape', |
||||
} |
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,198 @@ |
||||
import type { |
||||
PropsWithChildren, |
||||
ComponentProps, |
||||
CSSProperties, |
||||
} from 'react' |
||||
import { Fragment } from 'react' |
||||
|
||||
import compact from 'lodash/compact' |
||||
|
||||
import type { StepType } from '@reactour/tour' |
||||
import { TourProvider as TourProviderLib } from '@reactour/tour' |
||||
|
||||
import { isMobileDevice } from 'config' |
||||
|
||||
import { disableBodyScroll, enableBodyScroll } from 'helpers' |
||||
|
||||
import { useMatchPageStore } from 'features/MatchPage/store' |
||||
|
||||
import { Steps } from './config' |
||||
import { |
||||
ContentComponent, |
||||
Body, |
||||
BodyText, |
||||
Title, |
||||
} from './components/ContentComponent' |
||||
|
||||
const getPopoverStyle = (base: CSSProperties): CSSProperties => ({ |
||||
...base, |
||||
borderRadius: 6, |
||||
lineHeight: 1, |
||||
maxWidth: 'auto', |
||||
padding: 20, |
||||
width: 340, |
||||
...isMobileDevice && { |
||||
padding: '20px 25px', |
||||
textAlign: 'center', |
||||
width: '95vw', |
||||
}, |
||||
}) |
||||
|
||||
const getPopoverPosition = (baseCoords: [number, number]): [number, number] => ( |
||||
isMobileDevice ? [0.001, 0.001] : baseCoords |
||||
) |
||||
|
||||
const getSelector = (step: Steps) => ( |
||||
isMobileDevice ? `[data-step="${Steps.Start}"]` : `[data-step="${step}"]` |
||||
) |
||||
|
||||
const steps: Array<StepType> = compact([ |
||||
{ |
||||
content: ( |
||||
<Fragment> |
||||
<Title |
||||
alignLeft |
||||
t='check_out_the_stats' |
||||
/> |
||||
<Body> |
||||
<BodyText t='here_you_will_discover_tons' /> |
||||
</Body> |
||||
</Fragment> |
||||
), |
||||
padding: { |
||||
popover: getPopoverPosition([15, 10]), |
||||
}, |
||||
selector: getSelector(Steps.Start), |
||||
styles: { |
||||
popover: (base) => ({ |
||||
...getPopoverStyle(base), |
||||
textAlign: 'left', |
||||
}), |
||||
}, |
||||
}, |
||||
{ |
||||
content: ( |
||||
<Fragment> |
||||
<Title t='welcom_to_stats_tab' /> |
||||
<Body> |
||||
<BodyText t='see_interactive_game_stats' /> |
||||
</Body> |
||||
</Fragment> |
||||
), |
||||
padding: { |
||||
popover: getPopoverPosition([5, 0.001]), |
||||
}, |
||||
selector: getSelector(Steps.Welcome), |
||||
styles: { |
||||
popover: (base) => ({ |
||||
...getPopoverStyle(base), |
||||
...isMobileDevice && { |
||||
padding: '20px 0', |
||||
}, |
||||
}), |
||||
}, |
||||
}, |
||||
{ |
||||
content: ( |
||||
<Fragment> |
||||
<Title t='compare_teams_across_multiple_metrics' /> |
||||
<Body> |
||||
<BodyText t='blue_stats_are_clickable' /> |
||||
</Body> |
||||
</Fragment> |
||||
), |
||||
padding: { |
||||
popover: getPopoverPosition([8, 20]), |
||||
}, |
||||
selector: getSelector(Steps.TeamsTab), |
||||
}, |
||||
{ |
||||
content: ( |
||||
<Title t='click_to_watch_playlist' /> |
||||
), |
||||
selector: getSelector(Steps.ClickToWatchPlaylist), |
||||
}, |
||||
{ |
||||
content: ( |
||||
<Title t='team_players_stats' /> |
||||
), |
||||
padding: { |
||||
popover: getPopoverPosition([10, 17]), |
||||
}, |
||||
selector: getSelector(Steps.PlayersTab), |
||||
}, |
||||
{ |
||||
content: ( |
||||
<Title t='show_more_stats' /> |
||||
), |
||||
selector: getSelector(Steps.ShowMoreStats), |
||||
}, |
||||
!isMobileDevice && { |
||||
content: ( |
||||
<Title t='show_less_stats' /> |
||||
), |
||||
padding: { |
||||
popover: [20, 10], |
||||
}, |
||||
selector: getSelector(Steps.ShowLessStats), |
||||
}, |
||||
{ |
||||
content: ( |
||||
<Title t='click_to_see_full_time_stats' /> |
||||
), |
||||
padding: { |
||||
popover: getPopoverPosition([10, 0.001]), |
||||
}, |
||||
selector: getSelector(Steps.FinalStats), |
||||
}, |
||||
{ |
||||
content: ( |
||||
<Title t='click_to_see_stats_in_real_time' /> |
||||
), |
||||
padding: { |
||||
popover: getPopoverPosition([10, 0.001]), |
||||
}, |
||||
selector: getSelector(Steps.FinalStats), |
||||
}, |
||||
]) |
||||
|
||||
const styles: ComponentProps<typeof TourProviderLib>['styles'] = { |
||||
maskWrapper: () => ({ |
||||
display: 'none', |
||||
}), |
||||
popover: getPopoverStyle, |
||||
} |
||||
|
||||
const padding: ComponentProps<typeof TourProviderLib>['padding'] = { |
||||
popover: isMobileDevice ? [0.001, 0.001] : [15, 25], |
||||
} |
||||
|
||||
export const TourProvider = ({ children }: PropsWithChildren<{}>) => { |
||||
const { beforeCloseTourCallback } = useMatchPageStore() |
||||
|
||||
const afterOpen = (target: Element | null) => { |
||||
target && disableBodyScroll(target) |
||||
} |
||||
|
||||
const beforeClose = (target: Element | null) => { |
||||
target && enableBodyScroll(target) |
||||
beforeCloseTourCallback() |
||||
} |
||||
|
||||
return ( |
||||
<TourProviderLib |
||||
steps={steps} |
||||
ContentComponent={ContentComponent} |
||||
showDots={false} |
||||
afterOpen={afterOpen} |
||||
beforeClose={beforeClose} |
||||
position={isMobileDevice ? 'top' : 'left'} |
||||
padding={padding} |
||||
disableInteraction |
||||
disableKeyboardNavigation |
||||
styles={styles} |
||||
> |
||||
{children} |
||||
</TourProviderLib> |
||||
) |
||||
} |
||||
@ -0,0 +1,161 @@ |
||||
import { |
||||
useEffect, |
||||
useRef, |
||||
useMemo, |
||||
useCallback, |
||||
} from 'react' |
||||
|
||||
import throttle from 'lodash/throttle' |
||||
|
||||
import type { PopoverContentProps } from '@reactour/tour' |
||||
|
||||
import { isMobileDevice, KEYBOARD_KEYS } from 'config' |
||||
|
||||
import { useEventListener } from 'hooks' |
||||
|
||||
import { useMatchPageStore } from 'features/MatchPage/store' |
||||
import { Tabs } from 'features/MatchSidePlaylists/config' |
||||
import { StatsType, Tabs as StatTabs } from 'features/MatchSidePlaylists/components/TabStats/config' |
||||
|
||||
import { Steps } from '../../config' |
||||
|
||||
const KEY_PRESS_DELAY = 1500 |
||||
|
||||
export const useContentComponent = ({ |
||||
currentStep, |
||||
setCurrentStep, |
||||
setIsOpen, |
||||
steps, |
||||
}: PopoverContentProps) => { |
||||
const { |
||||
setSelectedStatsTable, |
||||
setSelectedTab, |
||||
setStatsType, |
||||
toggleIsExpanded, |
||||
} = useMatchPageStore() |
||||
|
||||
const timerRef = useRef<NodeJS.Timeout>() |
||||
|
||||
const back = useCallback(() => { |
||||
switch (currentStep) { |
||||
case Steps.Start: |
||||
case Steps.Welcome: |
||||
return |
||||
case Steps.PlayersTab: |
||||
setSelectedStatsTable(StatTabs.TEAMS) |
||||
break |
||||
case Steps.ShowLessStats: |
||||
if (!isMobileDevice) { |
||||
toggleIsExpanded() |
||||
} |
||||
break |
||||
case Steps.FinalStats: |
||||
if (isMobileDevice) { |
||||
setStatsType(StatsType.FINAL_STATS) |
||||
} else { |
||||
toggleIsExpanded() |
||||
} |
||||
break |
||||
|
||||
case Steps.CurrentStats: |
||||
setStatsType(StatsType.FINAL_STATS) |
||||
break |
||||
|
||||
default: |
||||
} |
||||
|
||||
timerRef.current = setTimeout(() => setCurrentStep((step) => step - 1), 0) |
||||
}, [ |
||||
currentStep, |
||||
setCurrentStep, |
||||
setSelectedStatsTable, |
||||
setStatsType, |
||||
toggleIsExpanded, |
||||
]) |
||||
|
||||
const next = useCallback(() => { |
||||
switch (currentStep) { |
||||
case steps.length - 1: |
||||
return |
||||
case Steps.Start: |
||||
setSelectedTab(Tabs.STATS) |
||||
break |
||||
|
||||
case Steps.ClickToWatchPlaylist: |
||||
setSelectedStatsTable(StatTabs.TEAM1) |
||||
break |
||||
|
||||
case Steps.ShowMoreStats: |
||||
if (isMobileDevice) { |
||||
setStatsType(StatsType.FINAL_STATS) |
||||
} else { |
||||
toggleIsExpanded() |
||||
} |
||||
break |
||||
|
||||
case Steps.ShowLessStats: |
||||
if (isMobileDevice) { |
||||
setStatsType(StatsType.CURRENT_STATS) |
||||
} else { |
||||
setStatsType(StatsType.FINAL_STATS) |
||||
toggleIsExpanded() |
||||
} |
||||
break |
||||
|
||||
case Steps.FinalStats: |
||||
if (!isMobileDevice) { |
||||
setStatsType(StatsType.CURRENT_STATS) |
||||
} |
||||
break |
||||
|
||||
default: |
||||
} |
||||
|
||||
timerRef.current = setTimeout(() => setCurrentStep((step) => step + 1), 0) |
||||
}, [ |
||||
currentStep, |
||||
setCurrentStep, |
||||
setSelectedStatsTable, |
||||
setSelectedTab, |
||||
setStatsType, |
||||
toggleIsExpanded, |
||||
steps.length, |
||||
]) |
||||
|
||||
const skipTour = useCallback(() => { |
||||
setIsOpen(false) |
||||
}, [setIsOpen]) |
||||
|
||||
useEventListener({ |
||||
callback: useMemo(() => throttle((e: KeyboardEvent) => { |
||||
e.stopPropagation() |
||||
|
||||
switch (e.code) { |
||||
case KEYBOARD_KEYS.ArrowLeft: |
||||
back() |
||||
break |
||||
|
||||
case KEYBOARD_KEYS.ArrowRight: |
||||
next() |
||||
break |
||||
|
||||
case KEYBOARD_KEYS.Esc: |
||||
skipTour() |
||||
break |
||||
default: |
||||
} |
||||
}, KEY_PRESS_DELAY), [back, next, skipTour]), |
||||
event: 'keydown', |
||||
options: true, |
||||
}) |
||||
|
||||
useEffect(() => () => { |
||||
timerRef.current && clearTimeout(timerRef.current) |
||||
}, []) |
||||
|
||||
return { |
||||
back, |
||||
next, |
||||
skipTour, |
||||
} |
||||
} |
||||
@ -0,0 +1,123 @@ |
||||
import type { ComponentType } from 'react' |
||||
import { Fragment } from 'react' |
||||
|
||||
import type { PopoverContentProps } from '@reactour/tour' |
||||
|
||||
import { isMobileDevice } from 'config' |
||||
|
||||
import { T9n } from 'features/T9n' |
||||
|
||||
import { useContentComponent } from './hooks' |
||||
import { |
||||
PrevButton, |
||||
NextButton, |
||||
ActionButtonsContainer, |
||||
Counter, |
||||
SkipTour, |
||||
ArrowWrapper, |
||||
} from './styled' |
||||
|
||||
import { Steps } from '../../config' |
||||
|
||||
export * from './styled' |
||||
|
||||
const Arrow = () => ( |
||||
<ArrowWrapper |
||||
width='7' |
||||
height='7' |
||||
viewBox='0 0 7 7' |
||||
fill='none' |
||||
> |
||||
<path |
||||
d='M5.25 3.06699C5.58333 3.25944 5.58333 3.74056 5.25 3.93301L1.5 6.09808C1.16667 6.29053 0.75 6.04996 0.75 5.66506L0.75 1.33494C0.75 0.950036 1.16667 0.709474 1.5 0.901924L5.25 3.06699Z' |
||||
fill='black' |
||||
fillOpacity='0.7' |
||||
/> |
||||
</ArrowWrapper> |
||||
|
||||
) |
||||
|
||||
export const ContentComponent: ComponentType<PopoverContentProps> = (props) => { |
||||
const { |
||||
back, |
||||
next, |
||||
skipTour, |
||||
} = useContentComponent(props) |
||||
|
||||
const { currentStep, steps } = props |
||||
|
||||
const renderActionButtons = () => { |
||||
switch (currentStep) { |
||||
case Steps.Start: |
||||
return ( |
||||
<Fragment> |
||||
<NextButton onClick={next}> |
||||
<T9n t='start_tour' /> |
||||
</NextButton> |
||||
<SkipTour onClick={skipTour}> |
||||
<T9n t='skip_tour' /> |
||||
</SkipTour> |
||||
</Fragment> |
||||
) |
||||
|
||||
case Steps.Welcome: |
||||
return ( |
||||
<Fragment> |
||||
<Counter> |
||||
{currentStep}/{steps.length - 1} |
||||
</Counter> |
||||
<NextButton onClick={next}> |
||||
<T9n t='next_step' /> |
||||
</NextButton> |
||||
<SkipTour onClick={skipTour}> |
||||
<T9n t='skip_tour' /> |
||||
</SkipTour> |
||||
</Fragment> |
||||
) |
||||
|
||||
case steps.length - 1: |
||||
return ( |
||||
<Fragment> |
||||
<Counter> |
||||
{currentStep}/{steps.length - 1} |
||||
</Counter> |
||||
<PrevButton isLastStep onClick={back}> |
||||
<T9n t='back' /> |
||||
</PrevButton> |
||||
<NextButton onClick={skipTour}> |
||||
<T9n t='end_tour' /> |
||||
</NextButton> |
||||
</Fragment> |
||||
) |
||||
|
||||
default: |
||||
return ( |
||||
<Fragment> |
||||
<Counter> |
||||
{currentStep}/{steps.length - 1} |
||||
</Counter> |
||||
<PrevButton onClick={back}> |
||||
<T9n t='back' /> |
||||
</PrevButton> |
||||
<NextButton onClick={next}> |
||||
<T9n t='next_step' /> |
||||
</NextButton> |
||||
<SkipTour onClick={skipTour}> |
||||
<T9n t='skip_tour' /> |
||||
</SkipTour> |
||||
</Fragment> |
||||
) |
||||
} |
||||
} |
||||
|
||||
return ( |
||||
<Fragment> |
||||
{steps[currentStep].content} |
||||
<ActionButtonsContainer step={currentStep}> |
||||
{renderActionButtons()} |
||||
</ActionButtonsContainer> |
||||
{!isMobileDevice && <Arrow />} |
||||
</Fragment> |
||||
) |
||||
} |
||||
|
||||
@ -0,0 +1,158 @@ |
||||
import styled, { css } from 'styled-components/macro' |
||||
|
||||
import includes from 'lodash/includes' |
||||
|
||||
import { isMobileDevice } from 'config' |
||||
|
||||
import { T9n } from 'features/T9n' |
||||
|
||||
import { Steps } from '../../config' |
||||
|
||||
const NavButton = styled.button` |
||||
padding: 0; |
||||
border: none; |
||||
font-size: 12px; |
||||
font-weight: 700; |
||||
white-space: nowrap; |
||||
text-transform: uppercase; |
||||
text-decoration: none; |
||||
background: none; |
||||
cursor: pointer; |
||||
|
||||
${isMobileDevice |
||||
? css` |
||||
font-size: 15px; |
||||
` |
||||
: ''} |
||||
` |
||||
|
||||
type PrevButtonProps = { |
||||
isLastStep?: boolean, |
||||
} |
||||
|
||||
export const PrevButton = styled(NavButton)<PrevButtonProps>` |
||||
color: rgba(0, 0, 0, 0.5); |
||||
` |
||||
|
||||
export const NextButton = styled(NavButton)` |
||||
color: #294FC3; |
||||
` |
||||
|
||||
export const SkipTour = styled.button` |
||||
margin-top: -2px; |
||||
padding: 0; |
||||
border: none; |
||||
font-weight: 400; |
||||
font-size: 12px; |
||||
color: rgba(0, 0, 0, 0.5); |
||||
text-decoration: underline; |
||||
text-transform: uppercase; |
||||
cursor: pointer; |
||||
background: none; |
||||
|
||||
${isMobileDevice |
||||
? css` |
||||
font-size: 15px; |
||||
` |
||||
: ''} |
||||
` |
||||
|
||||
export const Counter = styled.div` |
||||
color: rgba(0, 0, 0, 0.5); |
||||
font-size: 12px; |
||||
font-weight: 700; |
||||
white-space: nowrap; |
||||
|
||||
${isMobileDevice |
||||
? css` |
||||
font-size: 15px; |
||||
` |
||||
: ''} |
||||
` |
||||
|
||||
type TitleProps = { |
||||
alignLeft?: boolean, |
||||
} |
||||
|
||||
export const Title = styled(T9n)<TitleProps>` |
||||
display: block; |
||||
margin-bottom: 10px; |
||||
font-size: 14px; |
||||
font-weight: 700; |
||||
line-height: 17px; |
||||
|
||||
${isMobileDevice |
||||
? css` |
||||
flex: 1; |
||||
display: flex; |
||||
justify-content: center; |
||||
flex-direction: column; |
||||
padding: 0 3%; |
||||
line-height: 20px; |
||||
font-size: 16px; |
||||
` |
||||
: ''} |
||||
|
||||
${({ alignLeft }) => (alignLeft |
||||
? css` |
||||
padding: 0; |
||||
` |
||||
: '')} |
||||
` |
||||
|
||||
export const Body = styled.div`
|
||||
margin-bottom: 15px; |
||||
` |
||||
|
||||
export const BodyText = styled(T9n)` |
||||
font-size: 14px; |
||||
line-height: 17px; |
||||
|
||||
${isMobileDevice |
||||
? css` |
||||
line-height: 20px; |
||||
font-size: 16px; |
||||
` |
||||
: ''} |
||||
` |
||||
|
||||
type ActionButtonsContainerProps = { |
||||
step: Steps, |
||||
} |
||||
|
||||
export const ActionButtonsContainer = styled.div<ActionButtonsContainerProps>` |
||||
display: flex; |
||||
align-items: center; |
||||
justify-content: center; |
||||
gap: 24px; |
||||
padding: 0; |
||||
justify-content: space-between; |
||||
|
||||
${isMobileDevice |
||||
? css` |
||||
padding: 0 5%; |
||||
margin: auto; |
||||
gap: 20px; |
||||
justify-content: center; |
||||
` |
||||
: ''} |
||||
|
||||
${({ step }) => (isMobileDevice && step === Steps.Start |
||||
? css` |
||||
justify-content: space-between; |
||||
padding: 0; |
||||
` |
||||
: '')} |
||||
|
||||
${({ step }) => (isMobileDevice && (includes([Steps.FinalStats, Steps.Welcome], step)) |
||||
? css` |
||||
padding: 0 20%; |
||||
` |
||||
: '')} |
||||
` |
||||
|
||||
export const ArrowWrapper = styled.svg` |
||||
position: absolute; |
||||
top: 24px; |
||||
right: 15px; |
||||
` |
||||
@ -0,0 +1,100 @@ |
||||
import { memo, useRef } from 'react' |
||||
|
||||
import { useTour } from '@reactour/tour' |
||||
|
||||
import styled, { css, keyframes } from 'styled-components/macro' |
||||
|
||||
import { isMobileDevice } from 'config' |
||||
|
||||
import { Steps } from '../../config' |
||||
|
||||
type WrapperProps = { |
||||
step: number, |
||||
} |
||||
|
||||
const getBaseSize = ({ step }: WrapperProps) => { |
||||
let baseSize = isMobileDevice ? 57 : 55 |
||||
|
||||
switch (step) { |
||||
case Steps.ClickToWatchPlaylist: |
||||
if (isMobileDevice) baseSize = 39 |
||||
break |
||||
case Steps.TeamsTab: |
||||
case Steps.PlayersTab: |
||||
case Steps.ShowMoreStats: |
||||
if (isMobileDevice) baseSize = 75 |
||||
break |
||||
case isMobileDevice ? Steps.ShowLessStats : Steps.FinalStats: |
||||
case isMobileDevice ? Steps.FinalStats : Steps.CurrentStats: |
||||
baseSize = 118 |
||||
break |
||||
|
||||
default: |
||||
} |
||||
|
||||
return baseSize |
||||
} |
||||
|
||||
const getAnimation = ({ step }: WrapperProps) => { |
||||
const baseSize = getBaseSize({ step }) |
||||
|
||||
return keyframes` |
||||
to { |
||||
scale: ${(baseSize + 5) / baseSize}; |
||||
} |
||||
` |
||||
} |
||||
|
||||
const Wrapper = styled.div<WrapperProps>` |
||||
position: absolute; |
||||
top: 50%; |
||||
left: 50%; |
||||
display: block; |
||||
width: ${getBaseSize}px; |
||||
height: ${getBaseSize}px; |
||||
border-radius: 100%; |
||||
border: 1px solid #0057FF; |
||||
translate: -50% -50%; |
||||
background: radial-gradient(50% 50% at 50% 50%, rgba(0, 87, 255, 0) 70.25%, rgba(0, 87, 255, 0.4) 100%); |
||||
animation: ${getAnimation} 0.8s ease-in-out infinite alternate; |
||||
z-index: 9999; |
||||
|
||||
${({ step }) => { |
||||
switch (step) { |
||||
case Steps.ShowMoreStats: |
||||
return css`left: 3px;` |
||||
|
||||
case Steps.ShowLessStats: |
||||
return isMobileDevice |
||||
? '' |
||||
: css`left: 0;` |
||||
|
||||
case isMobileDevice ? Steps.ShowLessStats : Steps.FinalStats: |
||||
case isMobileDevice ? Steps.FinalStats : Steps.CurrentStats: |
||||
return css` |
||||
right: -14px; |
||||
left: auto; |
||||
translate: 0 -50%; |
||||
` |
||||
|
||||
default: |
||||
return '' |
||||
} |
||||
}} |
||||
` |
||||
|
||||
const SpotlightFC = () => { |
||||
const ref = useRef<HTMLDivElement>(null) |
||||
|
||||
const { currentStep } = useTour() |
||||
|
||||
return ( |
||||
<Wrapper |
||||
ref={ref} |
||||
step={currentStep} |
||||
/> |
||||
) |
||||
} |
||||
|
||||
export const Spotlight = memo(SpotlightFC) |
||||
|
||||
@ -0,0 +1,2 @@ |
||||
export * from './ContentComponent' |
||||
export * from './Spotlight' |
||||
@ -0,0 +1,13 @@ |
||||
export enum Steps { |
||||
Start, |
||||
Welcome, |
||||
TeamsTab, |
||||
ClickToWatchPlaylist, |
||||
PlayersTab, |
||||
ShowMoreStats, |
||||
ShowLessStats, |
||||
FinalStats, |
||||
CurrentStats, |
||||
} |
||||
|
||||
export const TOUR_COMPLETED_STORAGE_KEY = 'tour_completed' |
||||
@ -0,0 +1,3 @@ |
||||
export * from './config' |
||||
export * from './TourProvider' |
||||
export * from './components' |
||||
@ -0,0 +1,267 @@ |
||||
/* eslint-disable no-param-reassign */ |
||||
import { isIOS } from 'config' |
||||
|
||||
type BodyScrollOptions = { |
||||
allowTouchMove?: ((el: EventTarget) => boolean) | undefined, |
||||
reserveScrollBarGap?: boolean | undefined, |
||||
} |
||||
|
||||
let previousBodyPosition: { |
||||
left: string, |
||||
position: string, |
||||
right: string, |
||||
top: string, |
||||
} | undefined |
||||
|
||||
let locks: Array<{ |
||||
options: BodyScrollOptions, |
||||
targetElement: HTMLElement | Element, |
||||
}> = [] |
||||
|
||||
let initialClientY = -1 |
||||
let documentListenerAdded = false |
||||
let previousBodyOverflowSetting: string | undefined |
||||
let previousBodyPaddingRight: string | undefined |
||||
|
||||
// returns true if `el` should be allowed to receive touchmove events.
|
||||
const allowTouchMove = (el: EventTarget | null) => locks.some((lock) => { |
||||
if (el && lock.options.allowTouchMove && lock.options.allowTouchMove(el)) { |
||||
return true |
||||
} |
||||
|
||||
return false |
||||
}) |
||||
|
||||
const preventDefault = (rawEvent?: Event) => { |
||||
const e = rawEvent || window.event |
||||
|
||||
// For the case whereby consumers adds a touchmove event listener to document.
|
||||
// Recall that we do document.addEventListener('touchmove', preventDefault, { passive: false })
|
||||
// in disableBodyScroll - so if we provide this opportunity to allowTouchMove, then
|
||||
// the touchmove event on document will break.
|
||||
// @ts-expect-error
|
||||
if (allowTouchMove(e.target)) { |
||||
return true |
||||
} |
||||
|
||||
/** Do not prevent if the event has more than one touch |
||||
* (usually meaning this is a multi touch gesture like pinch to zoom). |
||||
* */ |
||||
// @ts-expect-error
|
||||
if (e.touches.length > 1) return true |
||||
// @ts-expect-error
|
||||
if (e.preventDefault) e.preventDefault() |
||||
|
||||
return false |
||||
} |
||||
|
||||
const setPositionFixed = () => window.requestAnimationFrame(() => { |
||||
// If previousBodyPosition is already set, don't set it again.
|
||||
if (previousBodyPosition === undefined) { |
||||
previousBodyPosition = { |
||||
left: document.body.style.left, |
||||
position: document.body.style.position, |
||||
right: document.body.style.right, |
||||
top: document.body.style.top, |
||||
} |
||||
|
||||
// Update the dom inside an animation frame
|
||||
const { |
||||
innerHeight, |
||||
scrollX, |
||||
scrollY, |
||||
} = window |
||||
document.body.style.position = 'fixed' |
||||
// @ts-expect-error
|
||||
document.body.style.top = -scrollY |
||||
// @ts-expect-error
|
||||
document.body.style.left = -scrollX |
||||
// @ts-expect-error
|
||||
document.body.style.right = 0 |
||||
|
||||
setTimeout(() => window.requestAnimationFrame(() => { |
||||
// Attempt to check if the bottom bar appeared due to the position change
|
||||
const bottomBarHeight = innerHeight - window.innerHeight |
||||
if (bottomBarHeight && scrollY >= innerHeight) { |
||||
// Move the content further up so that the bottom bar doesn't hide it
|
||||
// @ts-expect-error
|
||||
document.body.style.top = -(scrollY + bottomBarHeight) |
||||
} |
||||
}), 300) |
||||
} |
||||
}) |
||||
|
||||
const setOverflowHidden = (options?: BodyScrollOptions) => { |
||||
// If previousBodyPaddingRight is already set, don't set it again.
|
||||
if (previousBodyPaddingRight === undefined) { |
||||
const reserveScrollBarGap = !!options && options.reserveScrollBarGap === true |
||||
const scrollBarGap = window.innerWidth - document.documentElement.clientWidth |
||||
|
||||
if (reserveScrollBarGap && scrollBarGap > 0) { |
||||
const computedBodyPaddingRight = parseInt(window.getComputedStyle(document.body).getPropertyValue('padding-right'), 10) |
||||
previousBodyPaddingRight = document.body.style.paddingRight |
||||
document.body.style.paddingRight = `${computedBodyPaddingRight + scrollBarGap}px` |
||||
} |
||||
} |
||||
|
||||
// If previousBodyOverflowSetting is already set, don't set it again.
|
||||
if (previousBodyOverflowSetting === undefined) { |
||||
previousBodyOverflowSetting = document.body.style.overflow |
||||
document.body.style.overflow = 'hidden' |
||||
} |
||||
} |
||||
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollHeight#Problems_and_solutions
|
||||
const isTargetElementTotallyScrolled = (targetElement: HTMLElement | Element | null) => ( |
||||
targetElement |
||||
? targetElement.scrollHeight - targetElement.scrollTop <= targetElement.clientHeight |
||||
: false |
||||
) |
||||
|
||||
const handleScroll = (event: TouchEvent, targetElement: HTMLElement | Element | null) => { |
||||
const clientY = event.targetTouches[0].clientY - initialClientY |
||||
|
||||
if (allowTouchMove(event.target)) { |
||||
return false |
||||
} |
||||
|
||||
if (targetElement && targetElement.scrollTop === 0 && clientY > 0) { |
||||
// element is at the top of its scroll.
|
||||
return preventDefault(event) |
||||
} |
||||
|
||||
if (isTargetElementTotallyScrolled(targetElement) && clientY < 0) { |
||||
// element is at the bottom of its scroll.
|
||||
return preventDefault(event) |
||||
} |
||||
|
||||
event.stopPropagation() |
||||
return true |
||||
} |
||||
|
||||
const restorePositionSetting = () => { |
||||
if (previousBodyPosition !== undefined) { |
||||
// Convert the position from "px" to Int
|
||||
const y = -parseInt(document.body.style.top, 10) |
||||
const x = -parseInt(document.body.style.left, 10) |
||||
|
||||
// Restore styles
|
||||
document.body.style.position = previousBodyPosition.position |
||||
document.body.style.top = previousBodyPosition.top |
||||
document.body.style.left = previousBodyPosition.left |
||||
document.body.style.right = previousBodyPosition.right |
||||
|
||||
// Restore scroll
|
||||
window.scrollTo(x, y) |
||||
|
||||
previousBodyPosition = undefined |
||||
} |
||||
} |
||||
|
||||
const restoreOverflowSetting = () => { |
||||
if (previousBodyPaddingRight !== undefined) { |
||||
document.body.style.paddingRight = previousBodyPaddingRight |
||||
|
||||
// Restore previousBodyPaddingRight to undefined so setOverflowHidden knows it
|
||||
// can be set again.
|
||||
previousBodyPaddingRight = undefined |
||||
} |
||||
|
||||
if (previousBodyOverflowSetting !== undefined) { |
||||
document.body.style.overflow = previousBodyOverflowSetting |
||||
|
||||
// Restore previousBodyOverflowSetting to undefined
|
||||
// so setOverflowHidden knows it can be set again.
|
||||
previousBodyOverflowSetting = undefined |
||||
} |
||||
} |
||||
|
||||
// Enables body scroll locking without breaking scrolling of a target element
|
||||
export const enableBodyScroll = (targetElement: HTMLElement | Element) => { |
||||
if (!targetElement) { |
||||
// eslint-disable-next-line no-console
|
||||
console.error('enableBodyScroll unsuccessful - targetElement must be provided when calling enableBodyScroll on IOS devices.') |
||||
return |
||||
} |
||||
|
||||
locks = locks.filter((lock) => lock.targetElement !== targetElement) |
||||
|
||||
if (isIOS) { |
||||
// @ts-expect-error
|
||||
targetElement.ontouchstart = null |
||||
// @ts-expect-error
|
||||
targetElement.ontouchmove = null |
||||
|
||||
if (documentListenerAdded && locks.length === 0) { |
||||
document.removeEventListener( |
||||
'touchmove', |
||||
preventDefault, |
||||
// @ts-expect-error
|
||||
{ passive: false }, |
||||
) |
||||
documentListenerAdded = false |
||||
} |
||||
} |
||||
|
||||
if (isIOS) { |
||||
restorePositionSetting() |
||||
} else { |
||||
restoreOverflowSetting() |
||||
} |
||||
} |
||||
|
||||
// Disable body scroll locking
|
||||
export const disableBodyScroll = ( |
||||
targetElement: HTMLElement | Element, options?: BodyScrollOptions, |
||||
) => { |
||||
// targetElement must be provided
|
||||
if (!targetElement) { |
||||
// eslint-disable-next-line no-console
|
||||
console.error('disableBodyScroll unsuccessful - targetElement must be provided when calling disableBodyScroll on IOS devices.') |
||||
return |
||||
} |
||||
|
||||
// disableBodyScroll must not have been called on this targetElement before
|
||||
if (locks.some((lock) => lock.targetElement === targetElement)) { |
||||
return |
||||
} |
||||
|
||||
const lock = { |
||||
options: options || {}, |
||||
targetElement, |
||||
} |
||||
|
||||
locks = [...locks, lock] |
||||
|
||||
if (isIOS) { |
||||
setPositionFixed() |
||||
} else { |
||||
setOverflowHidden(options) |
||||
} |
||||
|
||||
if (isIOS) { |
||||
// @ts-expect-error
|
||||
targetElement.ontouchstart = (event) => { |
||||
if (event.targetTouches.length === 1) { |
||||
// detect single touch.
|
||||
initialClientY = event.targetTouches[0].clientY |
||||
} |
||||
} |
||||
// @ts-expect-error
|
||||
targetElement.ontouchmove = (event) => { |
||||
if (event.targetTouches.length === 1) { |
||||
// detect single touch.
|
||||
handleScroll(event, targetElement) |
||||
} |
||||
} |
||||
|
||||
if (!documentListenerAdded) { |
||||
document.addEventListener( |
||||
'touchmove', |
||||
preventDefault, |
||||
{ passive: false }, |
||||
) |
||||
documentListenerAdded = true |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue