Skip to content

Commit 6fc2569

Browse files
rogaldhgreptile-apps[bot]C0mberry
authored
feat: Improve tabs on mobile (#897)
## Description Improve tabs to make them work better on mobile by replacing plain tabs with `<select>` ## Type of change - [x] Bug fix - [x] New feature ## Screenshots Desktop: <img width="932" height="231" alt="image" src="https://github.com/user-attachments/assets/1fedc73f-7753-4c3d-b7ec-fd1dfdd315ff" /> Mobile: <img width="515" height="202" alt="image" src="https://github.com/user-attachments/assets/d1e57b0c-6a76-4bbd-bd8d-95421e1aa05b" /> <img width="518" height="301" alt="image" src="https://github.com/user-attachments/assets/501e83e2-bea6-4bea-bc38-d1e682a214e1" /> ## Testing All tests work. `stories` are provided that perform checks for the proper number of tabs according to the tab nature (static/dynamic) ## Related Issues [HOO-107](https://linear.app/solana-fndn/issue/HOO-107) hoodieshq#50 hoodieshq#49 hoodieshq#48 hoodieshq#46 hoodieshq#57 ## Checklist - [x] My code follows the project's style guidelines - [x] I have added tests that prove my fix/feature works - [x] All tests pass locally and in CI - [x] I have run `build:info` script to update build information - [x] CI/CD checks pass - [x] I have included screenshots for protocol screens (if applicable) --------- Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: Tania Markina <tanyamarkina@list.ru>
1 parent 5e78dab commit 6fc2569

File tree

15 files changed

+748
-475
lines changed

15 files changed

+748
-475
lines changed

app/address/[address]/layout.tsx

Lines changed: 143 additions & 416 deletions
Large diffs are not rendered by default.

app/block/[slot]/layout.tsx

Lines changed: 19 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -9,18 +9,18 @@ import { Slot } from '@components/common/Slot';
99
import { TableCardBody } from '@components/common/TableCardBody';
1010
import { BlockProvider, FetchStatus, useBlock, useFetchBlock } from '@providers/block';
1111
import { useCluster } from '@providers/cluster';
12-
import { cn } from '@shared/utils';
1312
import { ClusterStatus } from '@utils/cluster';
1413
import { displayTimestamp, displayTimestampUtc } from '@utils/date';
1514
import { IBRL_EXPLORER_URL } from '@utils/env';
16-
import { useClusterPath } from '@utils/url';
17-
import Link from 'next/link';
18-
import { notFound, useSelectedLayoutSegment } from 'next/navigation';
15+
import { notFound, useSearchParams } from 'next/navigation';
1916
import React, { PropsWithChildren } from 'react';
2017
import { ExternalLink } from 'react-feather';
2118

19+
import { type NavigationTab, NavigationTabs } from '@/app/shared/ui/navigation-tabs';
20+
import { StickyHeader } from '@/app/shared/ui/sticky-header/StickyHeader';
2221
import { estimateRequestedComputeUnits } from '@/app/utils/compute-units-schedule';
2322
import { getEpochForSlot, getMaxComputeUnitsInBlock } from '@/app/utils/epoch-schedule';
23+
import { pickClusterParams } from '@/app/utils/url';
2424

2525
type Props = PropsWithChildren<{ params: { slot: string } }>;
2626

@@ -235,65 +235,28 @@ export default function BlockLayout({ children, params }: Props) {
235235
);
236236
}
237237

238-
const TABS: Tab[] = [
239-
{
240-
path: '',
241-
slug: 'history',
242-
title: 'Transactions',
243-
},
244-
{
245-
path: 'rewards',
246-
slug: 'rewards',
247-
title: 'Rewards',
248-
},
249-
{
250-
path: 'programs',
251-
slug: 'programs',
252-
title: 'Programs',
253-
},
254-
{
255-
path: 'accounts',
256-
slug: 'accounts',
257-
title: 'Accounts',
258-
},
238+
const TABS: NavigationTab[] = [
239+
{ path: '', title: 'Transactions' },
240+
{ path: 'rewards', title: 'Rewards' },
241+
{ path: 'programs', title: 'Programs' },
242+
{ path: 'accounts', title: 'Accounts' },
259243
];
260244

261-
type MoreTabs = 'history' | 'rewards' | 'programs' | 'accounts';
262-
263-
type Tab = {
264-
slug: MoreTabs;
265-
title: string;
266-
path: string;
267-
};
268-
269245
function MoreSection({ children, slot }: { children: React.ReactNode; slot: number }) {
246+
const searchParams = useSearchParams();
247+
const buildHref = React.useCallback(
248+
(path: string) => pickClusterParams(`/block/${slot}/${path}`, searchParams ?? undefined),
249+
[slot, searchParams],
250+
);
251+
270252
return (
271253
<>
272-
<div className="container">
273-
<div className="header">
274-
<div className="header-body pt-0">
275-
<ul className="nav nav-tabs nav-overflow header-tabs">
276-
{TABS.map(({ title, slug, path }) => (
277-
<TabLink key={slug} slot={slot} path={path} title={title} />
278-
))}
279-
</ul>
280-
</div>
254+
<StickyHeader>
255+
<div className="container">
256+
<NavigationTabs buildHref={buildHref} tabs={TABS} />
281257
</div>
282-
</div>
258+
</StickyHeader>
283259
{children}
284260
</>
285261
);
286262
}
287-
288-
function TabLink({ path, slot, title }: { path: string; slot: number; title: string }) {
289-
const tabPath = useClusterPath({ pathname: `/block/${slot}/${path}` });
290-
const selectedLayoutSegment = useSelectedLayoutSegment();
291-
const isActive = (selectedLayoutSegment === null && path === '') || selectedLayoutSegment === path;
292-
return (
293-
<li className="nav-item">
294-
<Link className={cn(isActive && 'active', 'nav-link')} href={tabPath} scroll={false}>
295-
{title}
296-
</Link>
297-
</li>
298-
);
299-
}

app/features/token-extensions/use-token-extension-navigation.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
22
import { useEffect, useState } from 'react';
33

4-
import { type MoreTabs } from '@/app/address/[address]/layout';
4+
import { type AddressTabPath } from '@/app/address/[address]/layout';
55
import { ParsedTokenExtension } from '@/app/components/account/types';
66
import { TokenExtension } from '@/app/validators/accounts/token-extension';
77

8-
// extract type for the tab to not loose the functionality of the token extension navigation
9-
const TOKEN_EXTENSIONS: Extract<MoreTabs, 'token-extensions'> = 'token-extensions';
8+
const TOKEN_EXTENSIONS: Extract<AddressTabPath, 'token-extensions'> = 'token-extensions';
109
const TOKEN_EXTENSIONS_COMPONENT = `/${TOKEN_EXTENSIONS}`;
1110

1211
function getHash() {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export { type NavigationTab } from './model/types';
2+
export { NavigationTabLink } from './ui/NavigationTabLink';
3+
export { NavigationTabs } from './ui/NavigationTabs';
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
'use client';
2+
3+
import React from 'react';
4+
5+
import { type NavigationTab } from './types';
6+
7+
export type NavigationTabsContextValue = {
8+
activeValue: string;
9+
buildHref: (path: string) => string;
10+
registerTab: (tab: NavigationTab) => void;
11+
renderTabLink: boolean;
12+
staticPaths: Set<string>;
13+
unregisterTab: (path: string) => void;
14+
};
15+
16+
export const NavigationTabsContext = React.createContext<NavigationTabsContextValue | undefined>(undefined);
17+
18+
export function useNavigationTabsContext() {
19+
const ctx = React.useContext(NavigationTabsContext);
20+
if (!ctx) throw new Error('Tab components must be used within NavigationTabs');
21+
return ctx;
22+
}
23+
24+
export function useTabRegistration() {
25+
const [registeredTabs, setRegisteredTabs] = React.useState<NavigationTab[]>([]);
26+
27+
const registerTab = React.useCallback((tab: NavigationTab) => {
28+
setRegisteredTabs(prev => {
29+
if (prev.some(t => t.path === tab.path)) return prev;
30+
return [...prev, tab];
31+
});
32+
}, []);
33+
34+
const unregisterTab = React.useCallback((path: string) => {
35+
setRegisteredTabs(prev => prev.filter(t => t.path !== path));
36+
}, []);
37+
38+
return { registerTab, registeredTabs, unregisterTab };
39+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export type NavigationTab<P extends string = string> = {
2+
path: P;
3+
title: string;
4+
};
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use client';
2+
3+
import React from 'react';
4+
5+
import { type NavigationTab } from './types';
6+
7+
function getTabsKey(tabs: NavigationTab[]) {
8+
return tabs.map(tab => `${tab.path}:${tab.title}`).join('|');
9+
}
10+
11+
function getVisibleTabsCount({
12+
moreMeasure,
13+
tabElements,
14+
tablist,
15+
}: {
16+
moreMeasure: HTMLDivElement;
17+
tabElements: HTMLElement[];
18+
tablist: HTMLDivElement;
19+
}) {
20+
const containerRect = tablist.getBoundingClientRect();
21+
const containerWidth = containerRect.width;
22+
23+
if (containerWidth === 0) return tabElements.length;
24+
25+
const gap = parseFloat(getComputedStyle(tablist).columnGap) || 0;
26+
const moreWidth = moreMeasure.getBoundingClientRect().width;
27+
const hiddenTabIndex = tabElements.findIndex((tabElement, index) => {
28+
const tabRight = tabElement.getBoundingClientRect().right - containerRect.left;
29+
const reservedMoreWidth = index < tabElements.length - 1 ? gap + moreWidth : 0;
30+
return tabRight + reservedMoreWidth > containerWidth;
31+
});
32+
33+
return hiddenTabIndex === -1 ? tabElements.length : hiddenTabIndex;
34+
}
35+
36+
export function useTabOverflow(allTabs: NavigationTab[]) {
37+
const tablistRef = React.useRef<HTMLDivElement>(null);
38+
const moreMeasureRef = React.useRef<HTMLDivElement>(null);
39+
40+
const allTabsKey = React.useMemo(() => getTabsKey(allTabs), [allTabs]);
41+
42+
const [measuring, setMeasuring] = React.useState(true);
43+
const [visibleCount, setVisibleCount] = React.useState<number>(allTabs.length);
44+
45+
React.useLayoutEffect(() => {
46+
setMeasuring(true);
47+
setVisibleCount(allTabs.length);
48+
}, [allTabs.length, allTabsKey]);
49+
50+
React.useLayoutEffect(() => {
51+
if (!measuring) return;
52+
53+
const tablist = tablistRef.current;
54+
if (!tablist) return;
55+
if (allTabs.length === 0) {
56+
setVisibleCount(0);
57+
setMeasuring(false);
58+
return;
59+
}
60+
61+
const moreMeasure = moreMeasureRef.current;
62+
if (!moreMeasure) return;
63+
64+
const tabElements = Array.from(tablist.querySelectorAll<HTMLElement>('[role="tab"]'));
65+
setVisibleCount(getVisibleTabsCount({ moreMeasure, tabElements, tablist }));
66+
setMeasuring(false);
67+
}, [allTabs.length, allTabsKey, measuring]);
68+
69+
React.useEffect(() => {
70+
const tablist = tablistRef.current;
71+
if (!tablist) return;
72+
73+
const observer = new ResizeObserver(() => setMeasuring(true));
74+
observer.observe(tablist);
75+
76+
return () => {
77+
observer.disconnect();
78+
};
79+
}, []);
80+
81+
const visibleTabs = measuring ? allTabs : allTabs.slice(0, visibleCount);
82+
const moreTabs = measuring ? [] : allTabs.slice(visibleCount);
83+
84+
return { measuring, moreMeasureRef, moreTabs, tablistRef, visibleTabs };
85+
}
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
'use client';
2+
3+
import React from 'react';
4+
5+
import { cn } from '@/app/components/shared/utils';
6+
import {
7+
NavigationTabsContext,
8+
useTabRegistration,
9+
} from '@/app/shared/ui/navigation-tabs/model/navigation-tabs-context';
10+
import { type NavigationTab } from '@/app/shared/ui/navigation-tabs/model/types';
11+
import { useTabOverflow } from '@/app/shared/ui/navigation-tabs/model/useTabOverflow';
12+
13+
import { MobileMoreDropdown } from './MobileMoreDropdown';
14+
import { TabLink } from './TabLink';
15+
16+
export type BaseNavigationTabsProps = {
17+
activeValue: string;
18+
buildHref: (path: string) => string;
19+
children?: React.ReactNode;
20+
className?: string;
21+
onSelectChange: (path: string) => void;
22+
tabs: NavigationTab[];
23+
};
24+
25+
export function BaseNavigationTabs({
26+
tabs,
27+
activeValue,
28+
onSelectChange,
29+
buildHref,
30+
children,
31+
className,
32+
}: BaseNavigationTabsProps) {
33+
const { registeredTabs, registerTab, unregisterTab } = useTabRegistration();
34+
35+
const staticPaths = React.useMemo(() => new Set(tabs.map(t => t.path)), [tabs]);
36+
37+
const contextValue = React.useMemo(
38+
() => ({ activeValue, buildHref, registerTab, renderTabLink: true, staticPaths, unregisterTab }),
39+
[activeValue, buildHref, registerTab, staticPaths, unregisterTab],
40+
);
41+
42+
const hiddenContextValue = React.useMemo(() => ({ ...contextValue, renderTabLink: false }), [contextValue]);
43+
44+
const allTabs = React.useMemo(
45+
() => [...tabs, ...registeredTabs.filter(t => !staticPaths.has(t.path))],
46+
[tabs, registeredTabs, staticPaths],
47+
);
48+
49+
const { measuring, moreMeasureRef, moreTabs, tablistRef, visibleTabs } = useTabOverflow(allTabs);
50+
51+
return (
52+
<NavigationTabsContext.Provider value={contextValue}>
53+
<div
54+
ref={tablistRef}
55+
role="tablist"
56+
className={cn('e-inline-flex e-w-full e-gap-3 e-overflow-hidden', className)}
57+
>
58+
{visibleTabs.map(tab => (
59+
<TabLink key={tab.path} path={tab.path} title={tab.title} />
60+
))}
61+
62+
{measuring && allTabs.length > 0 && (
63+
<div ref={moreMeasureRef} aria-hidden="true">
64+
<MobileMoreDropdown tabs={[]} />
65+
</div>
66+
)}
67+
68+
{moreTabs.length > 0 && <MobileMoreDropdown tabs={moreTabs} onSelectChange={onSelectChange} />}
69+
</div>
70+
71+
{children && (
72+
<NavigationTabsContext.Provider value={hiddenContextValue}>
73+
<div className="e-hidden">{children}</div>
74+
</NavigationTabsContext.Provider>
75+
)}
76+
</NavigationTabsContext.Provider>
77+
);
78+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use client';
2+
3+
import * as PopoverPrimitive from '@radix-ui/react-popover';
4+
import Link from 'next/link';
5+
import { ChevronDown } from 'react-feather';
6+
7+
import { Popover, PopoverContent, PopoverTrigger } from '@/app/components/shared/ui/popover';
8+
import { cn } from '@/app/components/shared/utils';
9+
import { useNavigationTabsContext } from '@/app/shared/ui/navigation-tabs/model/navigation-tabs-context';
10+
import { type NavigationTab } from '@/app/shared/ui/navigation-tabs/model/types';
11+
12+
import { tabLinkClassName } from './TabLink';
13+
14+
type MobileMoreDropdownProps = {
15+
onSelectChange?: (path: string) => void;
16+
tabs: NavigationTab[];
17+
};
18+
19+
export function MobileMoreDropdown({ tabs, onSelectChange }: MobileMoreDropdownProps) {
20+
const ctx = useNavigationTabsContext();
21+
const isActive = tabs.some(t => t.path === ctx.activeValue);
22+
23+
return (
24+
<Popover>
25+
<div className="e-ml-auto e-flex e-items-center">
26+
<div className="e-mr-3 e-h-3/5 e-border-0 e-border-l e-border-solid e-border-neutral-700" />
27+
<PopoverTrigger
28+
data-state={isActive ? 'active' : 'inactive'}
29+
className={cn(tabLinkClassName, 'e-inline-flex e-cursor-pointer e-items-center e-gap-1')}
30+
>
31+
More <ChevronDown size={12} />
32+
</PopoverTrigger>
33+
</div>
34+
<PopoverContent align="start" className="e-w-auto e-min-w-[8rem] e-p-1">
35+
{tabs.map(tab => (
36+
<PopoverPrimitive.Close key={tab.path} asChild>
37+
<Link
38+
href={ctx.buildHref(tab.path)}
39+
scroll={false}
40+
onClick={() => onSelectChange?.(tab.path)}
41+
data-state={tab.path === ctx.activeValue ? 'active' : 'inactive'}
42+
className={cn(
43+
'e-block e-rounded e-px-3 e-py-2',
44+
'e-text-sm e-no-underline',
45+
'e-text-outer-space-200 data-[state=active]:e-text-accent',
46+
'hover:e-bg-outer-space-800 hover:e-text-white',
47+
)}
48+
>
49+
{tab.title}
50+
</Link>
51+
</PopoverPrimitive.Close>
52+
))}
53+
</PopoverContent>
54+
</Popover>
55+
);
56+
}

0 commit comments

Comments
 (0)