update sticky bar for assets and collectibles (#665)

Co-authored-by: Felicio <felicio@users.noreply.github.com>
This commit is contained in:
marcelines
2025-06-26 11:41:22 +09:00
committed by GitHub
co-authored by Felicio
parent d6665d955f
commit 4740bd7dda
22 changed files with 421 additions and 220 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@status-im/wallet': patch
'portfolio': patch
'wallet': patch
---
update sticky bar for assets and collectibles
@@ -1,7 +1,6 @@
'use client'
import { CurrencyAmount } from '../../../../../_components/currency-amount'
import { NetworkLogo } from '../../../../../_components/network-logo'
import { CurrencyAmount, NetworkLogo } from '@status-im/wallet/components'
import type { ApiOutput, NetworkType } from '@status-im/wallet/data'
@@ -2,17 +2,19 @@ import { Suspense } from 'react'
import { Button, Tooltip } from '@status-im/components'
import { BuyIcon, ReceiveBlurIcon } from '@status-im/icons/20'
import { StickyHeaderContainer } from '@status-im/wallet/components'
import {
Balance,
CurrencyAmount,
StickyHeaderContainer,
} from '@status-im/wallet/components'
import { cx } from 'class-variance-authority'
import { notFound } from 'next/navigation'
import { MDXRemote } from 'next-mdx-remote/rsc'
import { ErrorBoundary } from 'react-error-boundary'
import { getAPIClient } from '../../../../..//data/api'
import { Balance } from '../../../../_components/balance'
import { BuyCryptoDrawer } from '../../../../_components/buy-crypto-drawer'
import { portfolioComponents } from '../../../../_components/content'
import { CurrencyAmount } from '../../../../_components/currency-amount'
import { ReceiveCryptoDrawer } from '../../../../_components/receive-crypto-drawer'
import { TokenAmount } from '../../../../_components/token-amount'
import { Chart } from '../_components/chart'
@@ -3,10 +3,9 @@
import { Button } from '@status-im/components'
import { ExternalIcon, OptionsIcon, SadIcon } from '@status-im/icons/20'
import { OpenseaIcon } from '@status-im/icons/social'
import { CurrencyAmount, NetworkLogo } from '@status-im/wallet/components'
import { getAPIClient } from '../../../../../../../data/api'
import { CurrencyAmount } from '../../../../../../_components/currency-amount'
import { NetworkLogo } from '../../../../../../_components/network-logo'
import { ImageLightbox } from './_components/image-lightbox'
import { InfoCard } from './_components/info-card'
@@ -1,5 +1,6 @@
import { Balance } from '@status-im/wallet/components'
import { getAPIClient } from '../../../../data/api'
import { Balance } from '../../../_components/balance'
import type { NetworkType } from '@status-im/wallet/data'
@@ -5,8 +5,8 @@ import { useMemo } from 'react'
import { CollectiblesGrid as CollectiblesList } from '@status-im/wallet/components'
import { useInfiniteQuery } from '@tanstack/react-query'
import { usePathname, useSearchParams } from 'next/navigation'
import { Link } from 'src/app/_components/link'
import { Link } from '../../../../_components/link'
import { DEFAULT_SORT } from '../../../../_constants'
import { useSearchAndSort } from '../../../../_hooks/use-search-and-sort'
@@ -2,13 +2,12 @@
import { Input } from '@status-im/components'
import { SearchIcon } from '@status-im/icons/20'
import { TabLink } from '@status-im/wallet/components'
import { DropdownSort, TabLink } from '@status-im/wallet/components'
import NextLink from 'next/link'
import { useParams, usePathname } from 'next/navigation'
import { match, P } from 'ts-pattern'
import { useSearchAndSort } from '../_hooks/use-search-and-sort'
import { AdminDropdownSort } from './dropdown-sort'
const checkPathnameAndReturnTabValue = (
pathname: string
@@ -67,7 +66,7 @@ const ActionButtons = () => {
clearable={!!inputValue}
aria-label="Search"
/>
<AdminDropdownSort
<DropdownSort
data={sortOptions}
onOrderByChange={onOrderByChange}
orderByColumn={orderByColumn}
@@ -1,65 +0,0 @@
import { useMemo } from 'react'
import { match } from 'ts-pattern'
type Props = {
value: number
/**
* Format options for currency display:
* - 'compact': Shortened format with K/M/B suffixes, ideal for large amounts (e.g., $1.5K, €2M)
* - 'standard': Regular currency format with 2 decimal places (e.g., $1,234.56, €789.00)
* - 'precise': Higher precision format with 4 significant digits, ideal for very small amounts (e.g., $0.0001234, €0.00006789)
*/
format: 'compact' | 'standard' | 'precise'
className?: string
}
// TODO: get this from the user's settings
const SYMBOL: 'EUR' | 'USD' = 'EUR'
export const CurrencyAmount = (props: Props) => {
const { value, format = 'standard', className } = props
const formatter = useMemo(
() =>
match(format)
.with(
'compact',
() =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: SYMBOL,
notation: 'compact',
minimumFractionDigits: 0,
maximumFractionDigits: 2,
})
)
.with(
'standard',
() =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: SYMBOL,
notation: 'standard',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})
)
.with(
'precise',
() =>
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: SYMBOL,
notation: 'standard',
minimumSignificantDigits: 4,
maximumSignificantDigits: 4,
roundingPriority: 'morePrecision',
})
)
.exhaustive(),
[format]
)
return <div className={className}>{formatter.format(value)}</div>
}
@@ -2,6 +2,7 @@
import { Avatar } from '@status-im/components'
import { InsightsIcon } from '@status-im/icons/20'
import { CurrencyAmount } from '@status-im/wallet/components'
import { cva, cx } from 'class-variance-authority'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
@@ -9,7 +10,6 @@ import { usePathname } from 'next/navigation'
import { Tooltip } from '../_components/tooltip'
import { useMediaQuery } from '../_hooks/use-media-query'
import { AddAddress } from './add-address'
import { CurrencyAmount } from './currency-amount'
import { FeatureEnabled } from './feature-enabled'
import type { CustomisationColorType } from '@status-im/components'
+1
View File
@@ -56,6 +56,7 @@
"react-hook-form": "^7.54.2",
"superjson": "^2.2.1",
"trpc-chrome": "^1.0.0",
"ts-pattern": "^5.7.1",
"vite-plugin-node-polyfills": "^0.23.0",
"zod": "^3.23.8"
},
@@ -0,0 +1,70 @@
'use client'
// import { Input } from '@status-im/components'
// import { SearchIcon } from '@status-im/icons/20'
import { DropdownSort } from '@status-im/wallet/components'
// import { match, P } from 'ts-pattern'
import { TabLink } from './tab-link'
// const checkPathnameAndReturnTabValue = (
// pathname: string,
// ): 'assets' | 'collectibles' => {
// return match(pathname)
// .with(P.string.includes('/assets'), () => 'assets')
// .with(P.string.includes('/collectibles'), () => 'collectibles')
// .otherwise(() => 'assets') as 'assets' | 'collectibles'
// }
// const placeholderText = {
// assets: 'Search asset name or symbol',
// collectibles: 'Search collection or collectible name',
// } as const
type Props = {
address: string
pathname: string
searchAndSortValues: {
inputValue: string
updateSearchParam: (value: string) => void
orderByColumn: string
ascending: boolean
onOrderByChange: (value: string | number, ascending: boolean) => void
sortOptions: Record<string, string>
}
}
const ActionButtons = (props: Props) => {
// const { address, pathname, searchAndSortValues } = props
const { address, searchAndSortValues } = props
// const placeholder = placeholderText[checkPathnameAndReturnTabValue(pathname)]
return (
<div className="flex place-content-between">
<div className="flex gap-1.5">
<TabLink href={`/${address}/assets`}>Assets</TabLink>
<TabLink href={`/${address}/collectibles`}>Collectibles</TabLink>
</div>
<div className="flex items-center gap-2">
{/* <Input
placeholder={placeholder}
icon={<SearchIcon />}
size="32"
value={searchAndSortValues.inputValue}
onChange={searchAndSortValues.updateSearchParam}
clearable={!!searchAndSortValues.inputValue}
aria-label="Search"
/> */}
<DropdownSort
data={searchAndSortValues.sortOptions}
onOrderByChange={searchAndSortValues.onOrderByChange}
orderByColumn={searchAndSortValues.orderByColumn}
ascending={searchAndSortValues.ascending}
/>
</div>
</div>
)
}
export { ActionButtons }
@@ -0,0 +1,164 @@
import { Avatar } from '@status-im/components'
import { Balance, StickyHeaderContainer } from '@status-im/wallet/components'
import type { Account } from '@status-im/wallet/components'
type Props = {
list: React.ReactNode
detail: React.ReactNode
isLoading?: boolean
}
// Mock data. todo? Replace with actual data
const SUMMARY = {
hidden: {
total_balance: 0,
total_eur: 0,
total_eur_24h_change: 0.0,
total_percentage_24h_change: 0.0,
},
visible: {
total_balance: 203.0,
total_eur: 203.0,
total_eur_24h_change: 10.0,
total_percentage_24h_change: 2.4,
},
}
// Includes mock data for actions buttons and options. todo? Replace with actual data
const actionsButtonsData = {
address: 'portfolio', // This should be the address of the account coming from the URL. Wrote to have active state
pathname: '/portfolio/assets',
searchAndSortValues: {
inputValue: '',
updateSearchParam: () => {},
orderByColumn: 'name',
ascending: true,
onOrderByChange: () => {},
sortOptions: {
name: 'Name',
balance: 'Balance',
'24h': '24H%',
value: 'Value',
price: 'Price',
},
},
}
// Mock data. todo? Replace with actual data
const account: Account = {
name: 'Peachy Wallet',
emoji: '🍑',
color: 'magenta',
address: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045',
}
const SplittedLayout = (props: Props) => {
const { list, detail, isLoading } = props
const [showHiddenSummary, setShowHiddenSummary] = useState(false)
const handleShowHiddenSummary = () => {
setShowHiddenSummary(!showHiddenSummary)
}
return (
<div className="grid flex-1 divide-x divide-neutral-10 overflow-hidden">
{/* Main content */}
<div className="flex divide-x divide-default-neutral-20">
<div className="flex grow flex-col 2xl:basis-1/2">
<div className="relative h-[calc(100vh-56px)] overflow-auto">
{isLoading ? (
<div className="flex min-h-full items-center justify-center">
<div className="size-5 animate-spin rounded-full border-b-2 border-neutral-50"></div>
</div>
) : (
<StickyHeaderContainer
isLarge
className="px-6 xl:px-12"
leftSlot={
<>
<div
className="hidden items-center gap-1.5 xl:flex"
data-customisation={account.color}
>
<Avatar
type="account"
name={account.name}
emoji={account.emoji}
size="24"
bgOpacity="20"
/>
<div className="text-15 font-semibold text-neutral-100">
{account.name}
</div>
</div>
<Balance
summary={
showHiddenSummary ? SUMMARY.visible : SUMMARY.hidden
}
onShowHiddenSummary={handleShowHiddenSummary}
/>
</>
}
rightSlot={
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center">
<TabLink
href="/portfolio/assets"
className="w-full justify-center text-center sm:w-fit"
>
Assets
</TabLink>
<TabLink href="/portfolio/collectibles">
Collectibles
</TabLink>
</div>
}
>
<div className="relative -mt-8 flex flex-1 flex-col px-3 xl:mt-0 xl:px-12">
<div className="mb-5 flex flex-col gap-2 px-3">
<div
className="hidden items-center gap-1.5 xl:flex"
data-customisation={account.color}
>
<Avatar
type="account"
name={account.name}
emoji={account.emoji}
size="24"
bgOpacity="20"
/>
<div className="text-15 font-semibold text-neutral-100">
{account.name}
</div>
</div>
<div className="mb-4">
<Balance
summary={
showHiddenSummary ? SUMMARY.visible : SUMMARY.hidden
}
onShowHiddenSummary={handleShowHiddenSummary}
/>
</div>
<ActionButtons {...actionsButtonsData} />
</div>
{list}
</div>
</StickyHeaderContainer>
)}
</div>
</div>
<div className="hidden basis-1/2 flex-col bg-neutral-10 2xl:flex">
{/* {detail} */}
{detail}
</div>
</div>
</div>
)
}
export default SplittedLayout
+21
View File
@@ -0,0 +1,21 @@
import { getTabLinkClassName } from '@status-im/wallet/components'
import { Link, useRouterState } from '@tanstack/react-router'
type Props = {
href: string
children: React.ReactNode
className?: string
}
const TabLink = ({ href, children, className }: Props) => {
const { location } = useRouterState()
const isActive = location.pathname.startsWith(href)
return (
<Link to={href} className={getTabLinkClassName(isActive, className)}>
{children}
</Link>
)
}
export { TabLink }
+1 -1
View File
@@ -125,7 +125,7 @@ function RootComponent() {
<div className="fixed inset-x-0 bottom-0 flex justify-center gap-4 bg-blur-neutral-100/70 p-4 text-white-100">
<Link to="/">/index</Link>
<Link to="/onboarding">/onboarding</Link>
<Link to="/portfolio/assets">/portfolio/assets</Link>
<Link to="/portfolio/assets">/portfolio</Link>
</div>
</>
)
@@ -1,17 +1,14 @@
// import { Suspense } from 'react'
import { AssetsList, PinExtension, TabLink } from '@status-im/wallet/components'
import { AssetsList, PinExtension } from '@status-im/wallet/components'
import { useQuery } from '@tanstack/react-query'
import { createFileRoute, useRouterState } from '@tanstack/react-router'
import { createFileRoute } from '@tanstack/react-router'
import { Link } from '@/components/link'
import SplittedLayout from '@/components/splitted-layout'
import { usePinExtension } from '@/hooks/use-pin-extension'
import { useWallet } from '../../../providers/wallet-context'
// import { DetailDrawer } from '../../../../portfolio/src/app/[address]/@detail/_drawer'
// import { Loading as LoadingNav } from '../../../../portfolio/src/app/[address]/@nav/loading'
export const Route = createFileRoute('/portfolio/assets/')({
component: RouteComponent,
head: () => ({
@@ -24,8 +21,6 @@ export const Route = createFileRoute('/portfolio/assets/')({
})
function RouteComponent() {
const { location } = useRouterState()
const pathname = location.pathname
const { currentWallet, isLoading: isWalletLoading } = useWallet()
const { isPinExtension, handleClose } = usePinExtension()
@@ -93,52 +88,31 @@ function RouteComponent() {
}
return (
<div className="grid flex-1 divide-x divide-neutral-10 overflow-hidden">
<div className="flex divide-x divide-default-neutral-20">
<div className="flex grow flex-col">
<div className="sticky top-0 z-20 flex gap-3 px-3 py-2">
<TabLink
href="/portfolio/assets"
LinkComponent={Link}
isActive={pathname === '/portfolio/assets'}
>
Assets
</TabLink>
<TabLink href="/portfolio/collectibles" LinkComponent={Link}>
Collectibles
</TabLink>
</div>
<div className="h-[calc(100vh-100px)] overflow-auto px-3">
{isLoading ? (
<div className="flex min-h-full items-center justify-center">
<div className="size-5 animate-spin rounded-full border-b-2 border-neutral-50"></div>
</div>
) : (
<AssetsList
assets={assets}
onSelect={handleSelect}
clearSearch={() => {
// Clear the search input
console.log('Search cleared')
}}
searchParams={new URLSearchParams()}
pathname="/portfolio/"
/>
)}
</div>
</div>
<div className="hidden basis-1/2 flex-col bg-neutral-10 2xl:flex">
{/* {detail} */}
</div>
</div>
<>
<SplittedLayout
list={
assets ? (
<AssetsList
assets={assets}
onSelect={handleSelect}
clearSearch={() => {
console.log('Search cleared')
}}
searchParams={new URLSearchParams()}
pathname="/portfolio/"
/>
) : (
<div className="mt-4 flex flex-col gap-3">Empty state</div>
)
}
detail={<>Detail for asset</>}
isLoading={isLoading}
/>
{isPinExtension && (
<div className="absolute right-5 top-20">
<PinExtension onClose={handleClose} />
</div>
)}
</div>
</>
)
}
@@ -1,11 +1,10 @@
import {
CollectiblesGrid as CollectiblesList,
TabLink,
} from '@status-im/wallet/components'
import { CollectiblesGrid as CollectiblesList } from '@status-im/wallet/components'
import { useInfiniteQuery } from '@tanstack/react-query'
import { createFileRoute, useRouterState } from '@tanstack/react-router'
import { createFileRoute, Link as LinkBase } from '@tanstack/react-router'
import { Link } from '@/components/link'
import SplittedLayout from '@/components/splitted-layout'
import { useWallet } from '../../../providers/wallet-context'
import type { NetworkType } from '@status-im/wallet/data'
@@ -28,6 +27,21 @@ export const SORT_OPTIONS = {
},
} as const
type LinkProps = {
href: string
className?: string
children: React.ReactNode
}
const Link = (props: LinkProps) => {
const { href, className, children } = props
return (
<LinkBase to={href} className={className}>
{children}
</LinkBase>
)
}
export const Route = createFileRoute('/portfolio/collectibles/')({
component: RouteComponent,
head: () => ({
@@ -80,8 +94,7 @@ const getCollectibles = async (
}
function RouteComponent() {
const { location } = useRouterState()
const pathname = location.pathname
const { currentWallet, isLoading: isWalletLoading } = useWallet()
const handleSelect = (url: string, options?: { scroll?: boolean }) => {
// Handle the selection of an asset
@@ -95,7 +108,8 @@ function RouteComponent() {
const search = searchParams.get('search') ?? undefined
const sortParam = searchParams.get('sort')
const address = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045'
const pathname = window.location.pathname
const address = currentWallet?.activeAccounts[0].address
const sort = {
column:
(sortParam?.split(',')[0] as 'name' | 'collection') ||
@@ -118,6 +132,10 @@ function RouteComponent() {
useInfiniteQuery({
queryKey: ['collectibles', address, networks, search, sort],
queryFn: async ({ pageParam = 0 }) => {
if (!address) {
throw new Error('No wallet address available')
}
const collectibles = await getCollectibles(
address,
networks as NetworkType[],
@@ -131,6 +149,7 @@ function RouteComponent() {
},
getNextPageParam: lastPage => lastPage.nextPage,
initialPageParam: 0,
enabled: !!address && !isWalletLoading,
staleTime: 60 * 60 * 1000,
gcTime: 60 * 60 * 1000,
refetchOnMount: false,
@@ -142,53 +161,32 @@ function RouteComponent() {
return data?.pages.flatMap(page => page.collectibles ?? []) ?? []
}, [data?.pages])
return (
<div className="grid flex-1 divide-x divide-neutral-10 overflow-hidden">
<div className="flex divide-x divide-default-neutral-20">
{/* {list} */}
<div className="flex grow flex-col 2xl:basis-1/2">
<div className="sticky top-0 z-20 flex gap-3 px-3 py-2">
<TabLink href="/portfolio/assets" LinkComponent={Link}>
Assets
</TabLink>
<TabLink
href="/portfolio/collectibles"
LinkComponent={Link}
isActive={pathname === '/portfolio/collectibles'}
>
Collectibles
</TabLink>
</div>
<div className="h-[calc(100vh-100px)] overflow-auto px-3">
{isLoading ? (
<div className="flex min-h-full items-center justify-center">
<div className="size-5 animate-spin rounded-full border-b-2 border-neutral-50"></div>
</div>
) : (
<CollectiblesList
LinkComponent={Link}
address={address}
collectibles={collectibles}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
pathname={pathname}
search={search}
searchParams={searchParams}
clearSearch={() => {
// Clear the search input
console.log('Search cleared')
}}
hasNextPage={hasNextPage}
onSelect={handleSelect}
/>
)}
</div>
</div>
if (!currentWallet) {
return <div>No wallet selected</div>
}
<div className="hidden basis-1/2 flex-col bg-neutral-10 2xl:flex">
{/* {detail} */}
</div>
</div>
</div>
return (
<SplittedLayout
list={
<CollectiblesList
LinkComponent={Link}
address={address!}
collectibles={collectibles}
fetchNextPage={fetchNextPage}
isFetchingNextPage={isFetchingNextPage}
pathname={pathname}
search={search}
searchParams={searchParams}
clearSearch={() => {
// Clear the search input
console.log('Search cleared')
}}
hasNextPage={hasNextPage}
onSelect={handleSelect}
/>
}
detail={<>Detail</>}
isLoading={isLoading}
/>
)
}
@@ -1,11 +1,13 @@
import { PercentageChange } from '@status-im/wallet/components'
import { RevealIcon } from '@status-im/icons/20'
import { cva } from 'class-variance-authority'
import { CurrencyAmount } from './currency-amount'
import { CurrencyAmount } from '../currency-amount'
import { PercentageChange } from '../percentage-change'
import type { ApiOutput } from '@status-im/wallet/data'
type Props = {
onShowHiddenSummary?: () => void
variant?: 'token'
summary:
| ApiOutput['assets']['all']['summary']
@@ -23,7 +25,7 @@ const textColor = cva('text-13 font-medium', {
})
export const Balance = (props: Props) => {
const { variant, summary } = props
const { variant, summary, onShowHiddenSummary } = props
return (
<div>
@@ -39,6 +41,11 @@ export const Balance = (props: Props) => {
{summary.total_balance}
</div>
)}
{!!onShowHiddenSummary && (
<button onClick={onShowHiddenSummary}>
<RevealIcon className="text-neutral-50 transition-colors hover:text-neutral-60" />
</button>
)}
</div>
<div className="flex items-center gap-1.5">
@@ -41,7 +41,7 @@ const SortItem = <T,>(props: SortItemProps<T>) => {
'hidden aria-[current=true]:flex group-hover:flex',
selectedFilterAsc && 'opacity-[100%]',
selectedFilterDesc && 'opacity-[50%] hover:opacity-[100%]',
notSelectedFilter && 'opacity-[50%] hover:opacity-[100%]'
notSelectedFilter && 'opacity-[50%] hover:opacity-[100%]',
)}
aria-current={selectedFilterAsc}
onClick={() => onSelect(id, true)}
@@ -59,7 +59,7 @@ const SortItem = <T,>(props: SortItemProps<T>) => {
'hidden aria-[current=true]:flex group-hover:flex',
selectedFilterDesc && 'opacity-[100%]',
selectedFilterAsc && 'opacity-[50%] hover:opacity-[100%]',
notSelectedFilter && 'opacity-[50%] hover:opacity-[100%]'
notSelectedFilter && 'opacity-[50%] hover:opacity-[100%]',
)}
aria-current={selectedFilterDesc}
onClick={() => onSelect(id, false)}
@@ -78,7 +78,7 @@ const SortItem = <T,>(props: SortItemProps<T>) => {
)
}
const AdminDropdownSort = <T,>(props: Props<T>) => {
const DropdownSort = <T,>(props: Props<T>) => {
const { data, orderByColumn, ascending, onOrderByChange } = props
const onChange = (value: keyof T, ascending: boolean) => {
@@ -106,4 +106,4 @@ const AdminDropdownSort = <T,>(props: Props<T>) => {
)
}
export { AdminDropdownSort }
export { DropdownSort }
+3 -1
View File
@@ -3,6 +3,7 @@ export * from '../utils/variants'
export { AccountMenu } from './account-menu'
export { type Account, Address, type AddressProps } from './address'
export { AssetsList } from './assets-list'
export { Balance } from './balance'
export { BlurredCircle } from './blurred-circle'
export { CollectiblesGrid } from './collectibles-grid'
export {
@@ -11,6 +12,7 @@ export {
} from './create-password-form'
export { CurrencyAmount } from './currency-amount'
export { DeleteAddressAlert } from './delete-address-alert'
export { DropdownSort } from './dropdown-sort'
export { Image, type ImageProps } from './image'
export {
ImportRecoveryPhraseForm,
@@ -31,5 +33,5 @@ export {
} from './shorten-address'
export { Slider, type SliderProps } from './slider'
export { StickyHeaderContainer } from './sticky-header-container'
export { TabLink } from './tab-link'
export { getTabLinkClassName, TabLink } from './tab-link'
export { Tooltip } from './tooltip'
@@ -36,7 +36,7 @@ const leftSlotStyles = cva('transition-opacity duration-100 ease-in-out', {
},
})
const secondaryleftSlotStyles = cva('transition-all duration-100 ease-in-out', {
const secondaryLeftSlotStyles = cva('transition-all duration-100 ease-in-out', {
variants: {
showSecondaryLeftSlot: {
false: 'pointer-events-none max-h-0 translate-y-full opacity-[0]',
@@ -57,6 +57,7 @@ const rightSlotStyles = cva('transition-all', {
type Props = {
leftSlot: React.ReactNode
secondaryLeftSlot?: React.ReactNode
isLarge?: boolean
rightSlot: React.ReactNode
children: React.ReactNode
className?: string
@@ -66,64 +67,69 @@ const SMALL_THRESHOLD = 42
const LARGE_THRESHOLD = 132
const StickyHeaderContainer = (props: Props) => {
const { leftSlot, rightSlot, secondaryLeftSlot, className, children } = props
const {
leftSlot,
rightSlot,
secondaryLeftSlot,
className,
children,
isLarge,
} = props
const hasSecondaryLeftSlot = Boolean(secondaryLeftSlot)
const scrollContainerRef = useRef<HTMLDivElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [collapsed, setCollapsed] = useState(false)
const [showRightSlot, setShowRightSlot] = useState(false)
const [showSecondaryLeftSlot, setShowSecondaryLeftSlot] = useState(false)
useEffect(() => {
const scrollContainer = scrollContainerRef.current
const threshold = SMALL_THRESHOLD
const rightSlotThreshold = !showSecondaryLeftSlot
? threshold
: LARGE_THRESHOLD
if (!scrollContainer) return
const container = containerRef.current
if (!container) return
const handleScroll = () => {
const scrollPosition = Math.round(scrollContainer.scrollTop)
const scrollTop = container.scrollTop
setCollapsed(scrollPosition > threshold)
setShowRightSlot(scrollPosition > rightSlotThreshold)
const threshold = SMALL_THRESHOLD
const rightThreshold = hasSecondaryLeftSlot ? LARGE_THRESHOLD : threshold
setCollapsed(scrollTop > threshold)
setShowRightSlot(scrollTop > rightThreshold)
setShowSecondaryLeftSlot(
hasSecondaryLeftSlot && scrollPosition >= LARGE_THRESHOLD,
hasSecondaryLeftSlot && scrollTop >= LARGE_THRESHOLD,
)
}
scrollContainer.addEventListener('scroll', handleScroll, {
passive: true,
})
container.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
return () => {
scrollContainer.removeEventListener('scroll', handleScroll)
}
}, [hasSecondaryLeftSlot, scrollContainerRef, showSecondaryLeftSlot])
return () => container.removeEventListener('scroll', handleScroll)
}, [hasSecondaryLeftSlot])
return (
<div
ref={scrollContainerRef}
className="flex flex-1 flex-col overflow-auto scrollbar-stable"
ref={containerRef}
className="relative h-[calc(100vh-56px)] overflow-auto scrollbar-stable"
>
<div
className={cx([
className={cx(
backgroundStyles({
collapsed,
size: showSecondaryLeftSlot ? 'large' : 'default',
size: isLarge
? 'large'
: showSecondaryLeftSlot
? 'large'
: 'default',
}),
className,
])}
)}
>
<div className="flex items-center justify-between">
<div className={leftSlotStyles({ collapsed })}>{leftSlot}</div>
<div className={rightSlotStyles({ showRightSlot })}>{rightSlot}</div>
</div>
<div className={secondaryleftSlotStyles({ showSecondaryLeftSlot })}>
<div className={secondaryLeftSlotStyles({ showSecondaryLeftSlot })}>
{secondaryLeftSlot}
</div>
</div>
@@ -2,6 +2,14 @@ import { cx } from 'class-variance-authority'
import type { ComponentType, ReactNode } from 'react'
export function getTabLinkClassName(isActive?: boolean, className?: string) {
return cx(
'flex items-center rounded-10 bg-neutral-10 px-3 py-1 text-15 font-medium text-neutral-100',
isActive && 'bg-neutral-50 text-white-100',
className,
)
}
type LinkComponentProps = {
href: string
className?: string
+8
View File
@@ -930,6 +930,9 @@ importers:
trpc-chrome:
specifier: ^1.0.0
version: 1.0.0(@trpc/client@10.45.2(@trpc/server@10.45.2))(@trpc/server@10.45.2)
ts-pattern:
specifier: ^5.7.1
version: 5.7.1
vite-plugin-node-polyfills:
specifier: ^0.23.0
version: 0.23.0(rollup@4.40.2)(vite@6.3.5(@types/node@22.7.5)(jiti@2.4.2)(less@4.2.0)(lightningcss@1.27.0)(sass@1.80.4)(tsx@4.19.4)(yaml@2.5.1))
@@ -16784,6 +16787,9 @@ packages:
ts-pattern@5.3.1:
resolution: {integrity: sha512-1RUMKa8jYQdNfmnK4jyzBK3/PS/tnjcZ1CW0v1vWDeYe5RBklc/nquw03MEoB66hVBm4BnlCfmOqDVxHyT1DpA==}
ts-pattern@5.7.1:
resolution: {integrity: sha512-EGs8PguQqAAUIcQfK4E9xdXxB6s2GK4sJfT/vcc9V1ELIvC4LH/zXu2t/5fajtv6oiRCxdv7BgtVK3vWgROxag==}
tsconfck@3.1.5:
resolution: {integrity: sha512-CLDfGgUp7XPswWnezWwsCRxNmgQjhYq3VXHM0/XIRxhVrKw0M1if9agzryh1QS3nxjCROvV+xWxoJO1YctzzWg==}
engines: {node: ^18 || >=20}
@@ -39321,6 +39327,8 @@ snapshots:
ts-pattern@5.3.1: {}
ts-pattern@5.7.1: {}
tsconfck@3.1.5(typescript@5.8.3):
optionalDependencies:
typescript: 5.8.3