scrolling mobile tabs

This commit is contained in:
saml33 2022-05-27 21:38:49 +10:00
parent 95bc49a6bd
commit f8205ea52b
8 changed files with 323 additions and 63 deletions

View File

@ -82,7 +82,7 @@ const MobileTradePage = () => {
</div>
<SwipeableTabs
onChange={handleChangeViewIndex}
tabs={TABS}
items={TABS}
tabIndex={viewIndex}
/>
<Swipeable index={viewIndex} onChangeIndex={handleChangeViewIndex}>

View File

@ -1,38 +1,217 @@
import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/outline'
import useDrag from 'hooks/useDrag'
import { useTranslation } from 'next-i18next'
import React, { useContext, useEffect, useState } from 'react'
import {
ScrollMenu,
VisibilityContext,
getItemsPos,
slidingWindow,
} from 'react-horizontal-scrolling-menu'
const SwipeableTabs = ({ onChange, tabs, tabIndex }) => {
const { t } = useTranslation('common')
type scrollVisibilityApiType = React.ContextType<typeof VisibilityContext>
function SwipeableTabs({ items, onChange, tabIndex }) {
const { dragStart, dragStop, dragMove, dragging } = useDrag()
const handleDrag =
({ scrollContainer }: scrollVisibilityApiType) =>
(ev: React.MouseEvent) =>
dragMove(ev, (posDiff) => {
if (scrollContainer.current) {
scrollContainer.current.scrollLeft += posDiff
}
})
const handleItemClick =
(itemId: string) =>
({ getItemById, scrollToItem }: scrollVisibilityApiType) => {
if (dragging) {
return false
}
onChange(parseInt(itemId))
scrollToItem(getItemById(itemId), 'smooth', 'center', 'nearest')
}
return (
<div className={`relative mb-4 border-b border-th-fgd-4`}>
<div
className={`default-transition absolute bottom-[-1px] left-0 h-0.5 bg-th-primary`}
style={{
maxWidth: '176px',
transform: `translateX(${tabIndex * 100}%)`,
width: `${100 / tabs.length}%`,
}}
/>
<nav className="-mb-px flex" aria-label="Tabs">
{tabs.map((tabName, i) => (
<a
key={tabName}
onClick={() => onChange(i)}
className={`default-transition relative flex cursor-pointer justify-center whitespace-nowrap pb-4 pt-2 font-semibold hover:opacity-100
${
tabIndex === i
? `text-th-primary`
: `text-th-fgd-4 hover:text-th-primary`
}
`}
style={{ width: `${100 / tabs.length}%`, maxWidth: '176px' }}
>
{t(tabName.toLowerCase().replace(' ', '-'))}
</a>
<div
onMouseLeave={dragStop}
className="thin-scroll relative mb-4 border-b border-th-fgd-4 px-4"
>
<ScrollMenu
LeftArrow={LeftArrow}
RightArrow={RightArrow}
onWheel={onWheel}
onMouseDown={() => dragStart}
onMouseUp={({
getItemById,
scrollToItem,
visibleItems,
}: scrollVisibilityApiType) =>
() => {
dragStop()
const { center } = getItemsPos(visibleItems)
scrollToItem(getItemById(center), 'smooth', 'center')
}}
options={{ throttle: 0 }}
onMouseMove={handleDrag}
onUpdate={({
getItemById,
scrollToItem,
visibleItems,
}: scrollVisibilityApiType) =>
() => {
dragStop()
const { center } = getItemsPos(visibleItems)
scrollToItem(getItemById(center), 'smooth', 'center')
}}
>
{items.map((item, i) => (
<Card
title={item}
itemId={i.toString()}
key={item}
onClick={handleItemClick(i.toString())}
selected={i === tabIndex}
/>
))}
</nav>
</ScrollMenu>
</div>
)
}
export default SwipeableTabs
function onWheel(
{ getItemById, items, visibleItems, scrollToItem }: scrollVisibilityApiType,
ev: React.WheelEvent
): void {
const isThouchpad = Math.abs(ev.deltaX) !== 0 || Math.abs(ev.deltaY) < 15
if (isThouchpad) {
ev.stopPropagation()
return
}
if (ev.deltaY < 0) {
const nextGroupItems = slidingWindow(
items.toItemsKeys(),
visibleItems
).next()
const { center } = getItemsPos(nextGroupItems)
scrollToItem(getItemById(center), 'smooth', 'center')
} else if (ev.deltaY > 0) {
const prevGroupItems = slidingWindow(
items.toItemsKeys(),
visibleItems
).prev()
const { center } = getItemsPos(prevGroupItems)
scrollToItem(getItemById(center), 'smooth', 'center')
}
}
function Card({
selected,
onClick,
title,
}: {
itemId: string
selected: boolean
onClick: (x) => void
title: string
}) {
const { t } = useTranslation('common')
const visibility = React.useContext(VisibilityContext)
useEffect(() => {
if (selected) {
onClick(visibility)
}
}, [selected])
return (
<div
onClick={() => onClick(visibility)}
role="button"
tabIndex={0}
className={`flex h-10 w-32 items-center justify-center ${
selected
? 'border-b-2 border-th-primary text-th-primary'
: 'border-b-2 border-transparent text-th-fgd-3'
}`}
>
{t(title)}
</div>
)
}
export default SwipeableTabs
function Arrow({
children,
disabled,
onClick,
className,
}: {
children: React.ReactNode
disabled: boolean
onClick: VoidFunction
className?: string
}) {
return (
<button
disabled={disabled}
onClick={onClick}
className={`top-1/2 -translate-y-1/2 transform rounded-none ${
disabled ? 'hidden' : 'absolute'
} ${className}`}
>
{children}
</button>
)
}
function LeftArrow() {
const {
isFirstItemVisible,
scrollPrev,
visibleItemsWithoutSeparators,
initComplete,
} = useContext(VisibilityContext)
const [disabled, setDisabled] = useState(
!initComplete || (initComplete && isFirstItemVisible)
)
useEffect(() => {
if (visibleItemsWithoutSeparators.length) {
setDisabled(isFirstItemVisible)
}
}, [isFirstItemVisible, visibleItemsWithoutSeparators])
return (
<Arrow disabled={disabled} onClick={() => scrollPrev()} className="-left-2">
<ChevronLeftIcon className="h-5 w-5 text-th-fgd-3" />
</Arrow>
)
}
function RightArrow() {
const { isLastItemVisible, scrollNext, visibleItemsWithoutSeparators } =
useContext(VisibilityContext)
const [disabled, setDisabled] = useState(
!visibleItemsWithoutSeparators.length && isLastItemVisible
)
useEffect(() => {
if (visibleItemsWithoutSeparators.length) {
setDisabled(isLastItemVisible)
}
}, [isLastItemVisible, visibleItemsWithoutSeparators])
return (
<Arrow
disabled={disabled}
onClick={() => scrollNext()}
className="-right-2"
>
<ChevronRightIcon className="h-5 w-5 text-th-fgd-3" />
</Arrow>
)
}

46
hooks/useDrag.tsx Normal file
View File

@ -0,0 +1,46 @@
import React from 'react'
export default function useDrag() {
const [clicked, setClicked] = React.useState(false)
const [dragging, setDragging] = React.useState(false)
const position = React.useRef(0)
const dragStart = React.useCallback((ev: React.MouseEvent) => {
position.current = ev.clientX
setClicked(true)
}, [])
const dragStop = React.useCallback(
() =>
// NOTE: need some delay so item under cursor won't be clicked
window.requestAnimationFrame(() => {
setDragging(false)
setClicked(false)
}),
[]
)
const dragMove = (ev: React.MouseEvent, cb: (posDif: number) => void) => {
const newDiff = position.current - ev.clientX
const movedEnough = Math.abs(newDiff) > 5
if (clicked && movedEnough) {
setDragging(true)
}
if (dragging && movedEnough) {
position.current = ev.clientX
cb(newDiff)
}
}
return {
dragStart,
dragStop,
dragMove,
dragging,
position,
setDragging,
}
}

View File

@ -49,6 +49,7 @@
"react-cool-dimensions": "^2.0.7",
"react-dom": "^17.0.2",
"react-grid-layout": "^1.3.3",
"react-horizontal-scrolling-menu": "^2.8.2",
"react-nice-dates": "^3.1.0",
"react-portal": "^4.2.1",
"react-qr-code": "^2.0.3",

View File

@ -57,6 +57,7 @@ import { handleWalletConnect } from 'components/ConnectWalletButton'
import { MangoAccountLookup } from 'components/account_page/MangoAccountLookup'
import NftProfilePicModal from 'components/NftProfilePicModal'
import ProfileImage from 'components/ProfileImage'
import SwipeableTabs from 'components/mobile/SwipeableTabs'
export async function getStaticProps({ locale }) {
return {
@ -448,40 +449,47 @@ export default function Account() {
) : null}
</div>
<div className="md:rounded-lg md:bg-th-bkg-2 md:p-6">
{mangoAccount ? (
<Tabs
activeTab={activeTab}
onChange={handleTabChange}
tabs={TABS}
/>
) : null}
{mangoAccount ? (
!isMobile ? (
<TabContent activeTab={activeTab} />
<>
<Tabs
activeTab={activeTab}
onChange={handleTabChange}
tabs={TABS}
/>
<TabContent activeTab={activeTab} />
</>
) : (
<Swipeable
index={viewIndex}
onChangeIndex={handleChangeViewIndex}
>
<div>
<AccountOverview />
</div>
<div>
<AccountOrders />
</div>
<div>
<AccountHistory />
</div>
<div>
<AccountInterest />
</div>
<div>
<AccountFunding />
</div>
<div>
<AccountPerformancePerToken />
</div>
</Swipeable>
<>
<SwipeableTabs
onChange={handleChangeViewIndex}
items={TABS}
tabIndex={viewIndex}
/>
<Swipeable
index={viewIndex}
onChangeIndex={handleChangeViewIndex}
>
<div>
<AccountOverview />
</div>
<div>
<AccountOrders />
</div>
<div>
<AccountHistory />
</div>
<div>
<AccountInterest />
</div>
<div>
<AccountFunding />
</div>
<div>
<AccountPerformancePerToken />
</div>
</Swipeable>
</>
)
) : connected ? (
isLoading ? (

View File

@ -54,7 +54,7 @@ export default function Markets() {
) : (
<SwipeableTabs
onChange={handleChangeViewIndex}
tabs={TABS}
items={TABS}
tabIndex={viewIndex}
/>
)}

View File

@ -62,7 +62,7 @@ export default function StatsPage() {
) : (
<SwipeableTabs
onChange={handleChangeViewIndex}
tabs={TABS}
items={TABS}
tabIndex={viewIndex}
/>
)}

View File

@ -3229,6 +3229,11 @@ commander@^8.0.0, commander@^8.3.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66"
integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==
compute-scroll-into-view@^1.0.17:
version "1.0.17"
resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz#6a88f18acd9d42e9cf4baa6bec7e0522607ab7ab"
integrity sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==
concat-map@0.0.1:
version "0.0.1"
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@ -5602,6 +5607,13 @@ react-grid-layout@^1.3.3:
react-draggable "^4.0.0"
react-resizable "^3.0.4"
react-horizontal-scrolling-menu@^2.8.2:
version "2.8.2"
resolved "https://registry.yarnpkg.com/react-horizontal-scrolling-menu/-/react-horizontal-scrolling-menu-2.8.2.tgz#cb7bc7c6798e0cd9203555cf4e6fcffb5f0ba0f2"
integrity sha512-OmCQDONx/4TnyxQr+RoxP7AwSPQxdQGkFnWuCknF3s2pySNAogL4AB1LQv6bUrXIwFMXdrJBqxFeq3tZ24J7/w==
dependencies:
smooth-scroll-into-view-if-needed "^1.1.32"
react-i18next@^11.15.5:
version "11.16.2"
resolved "https://registry.yarnpkg.com/react-i18next/-/react-i18next-11.16.2.tgz#650b18c12a624057ee2651ba4b4a989b526be554"
@ -6018,6 +6030,13 @@ scheduler@^0.20.2:
loose-envify "^1.1.0"
object-assign "^4.1.1"
scroll-into-view-if-needed@^2.2.28:
version "2.2.29"
resolved "https://registry.yarnpkg.com/scroll-into-view-if-needed/-/scroll-into-view-if-needed-2.2.29.tgz#551791a84b7e2287706511f8c68161e4990ab885"
integrity sha512-hxpAR6AN+Gh53AdAimHM6C8oTN1ppwVZITihix+WqalywBeFcQ6LdQP5ABNl26nX8GTEL7VT+b8lKpdqq65wXg==
dependencies:
compute-scroll-into-view "^1.0.17"
scrypt-js@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/scrypt-js/-/scrypt-js-3.0.1.tgz#d314a57c2aef69d1ad98a138a21fe9eafa9ee312"
@ -6145,6 +6164,13 @@ slice-ansi@^5.0.0:
ansi-styles "^6.0.0"
is-fullwidth-code-point "^4.0.0"
smooth-scroll-into-view-if-needed@^1.1.32:
version "1.1.33"
resolved "https://registry.yarnpkg.com/smooth-scroll-into-view-if-needed/-/smooth-scroll-into-view-if-needed-1.1.33.tgz#2c7b88c82784c69030cb0489b9df584e94e01533"
integrity sha512-crS8NfAaoPrtVYOCMSAnO2vHRgUp22NiiDgEQ7YiaAy5xe2jmR19Jm+QdL8+97gO8ENd7PUyQIAQojJyIiyRHw==
dependencies:
scroll-into-view-if-needed "^2.2.28"
snake-case@^3.0.4:
version "3.0.4"
resolved "https://registry.yarnpkg.com/snake-case/-/snake-case-3.0.4.tgz#4f2bbd568e9935abdfd593f34c691dadb49c452c"