Add Chain 138 wallet network metadata and stats coin-price enrichment; sync frontend explorer SPA, command center, and address/token pages with backend config. Co-authored-by: Cursor <cursoragent@cursor.com>
121 lines
3.3 KiB
TypeScript
121 lines
3.3 KiB
TypeScript
import { useCallback, useId } from 'react'
|
|
|
|
export interface SectionTab<T extends string> {
|
|
id: T
|
|
label: string
|
|
count?: number
|
|
}
|
|
|
|
interface SectionTabsProps<T extends string> {
|
|
tabs: SectionTab<T>[]
|
|
activeTab: T
|
|
onChange: (tab: T) => void
|
|
className?: string
|
|
idPrefix?: string
|
|
ariaLabel?: string
|
|
}
|
|
|
|
export function sectionTabPanelProps<T extends string>(
|
|
idPrefix: string,
|
|
tabId: T,
|
|
activeTab: T,
|
|
) {
|
|
return {
|
|
id: `${idPrefix}-panel-${tabId}`,
|
|
role: 'tabpanel' as const,
|
|
'aria-labelledby': `${idPrefix}-tab-${tabId}`,
|
|
hidden: activeTab !== tabId,
|
|
}
|
|
}
|
|
|
|
export default function SectionTabs<T extends string>({
|
|
tabs,
|
|
activeTab,
|
|
onChange,
|
|
className = '',
|
|
idPrefix,
|
|
ariaLabel = 'Sections',
|
|
}: SectionTabsProps<T>) {
|
|
const generatedId = useId().replace(/:/g, '')
|
|
const tabListPrefix = idPrefix || generatedId
|
|
|
|
const focusTab = useCallback(
|
|
(tabId: T) => {
|
|
const element = document.getElementById(`${tabListPrefix}-tab-${tabId}`)
|
|
element?.focus()
|
|
},
|
|
[tabListPrefix],
|
|
)
|
|
|
|
const handleKeyDown = useCallback(
|
|
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
|
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab)
|
|
if (currentIndex < 0) {
|
|
return
|
|
}
|
|
|
|
let nextIndex = currentIndex
|
|
|
|
if (event.key === 'ArrowRight') {
|
|
nextIndex = (currentIndex + 1) % tabs.length
|
|
} else if (event.key === 'ArrowLeft') {
|
|
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length
|
|
} else if (event.key === 'Home') {
|
|
nextIndex = 0
|
|
} else if (event.key === 'End') {
|
|
nextIndex = tabs.length - 1
|
|
} else {
|
|
return
|
|
}
|
|
|
|
event.preventDefault()
|
|
const nextTab = tabs[nextIndex]
|
|
onChange(nextTab.id)
|
|
focusTab(nextTab.id)
|
|
},
|
|
[activeTab, focusTab, onChange, tabs],
|
|
)
|
|
|
|
return (
|
|
<div
|
|
className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}
|
|
>
|
|
<div
|
|
role="tablist"
|
|
aria-label={ariaLabel}
|
|
className="flex gap-2 overflow-x-auto"
|
|
onKeyDown={handleKeyDown}
|
|
>
|
|
{tabs.map((tab) => {
|
|
const isActive = activeTab === tab.id
|
|
|
|
return (
|
|
<button
|
|
key={tab.id}
|
|
id={`${tabListPrefix}-tab-${tab.id}`}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
aria-controls={`${tabListPrefix}-panel-${tab.id}`}
|
|
tabIndex={isActive ? 0 : -1}
|
|
onClick={() => onChange(tab.id)}
|
|
className={
|
|
isActive
|
|
? 'whitespace-nowrap rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
|
: 'whitespace-nowrap rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
|
}
|
|
>
|
|
{tab.label}
|
|
{typeof tab.count === 'number' ? (
|
|
<span className={isActive ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
|
|
{tab.count.toLocaleString()}
|
|
</span>
|
|
) : null}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|