123 lines
3.6 KiB
TypeScript
123 lines
3.6 KiB
TypeScript
// /src/app/[locale]/components/Tabs.tsx
|
|
'use client'
|
|
|
|
import { usePathname } from 'next/navigation'
|
|
import Link from 'next/link'
|
|
import type { ReactNode, FC } from 'react'
|
|
import { Children, type ReactElement } from 'react';
|
|
|
|
export type TabProps = {
|
|
name: string
|
|
href: string
|
|
}
|
|
|
|
type TabsProps = {
|
|
children: ReactNode
|
|
value?: string
|
|
onChange?: (name: string) => void
|
|
orientation?: 'horizontal' | 'vertical'
|
|
className?: string
|
|
tabClassName?: string
|
|
}
|
|
|
|
// ── add a component type that has a static Tab
|
|
type TabsComponent = FC<TabsProps> & { Tab: FC<TabProps> }
|
|
|
|
function normalize(path: string) {
|
|
if (!path) return '/'
|
|
const v = path.replace(/\/+$/, '')
|
|
return v === '' ? '/' : v
|
|
}
|
|
|
|
function isTabElement(v: unknown): v is ReactElement<TabProps> {
|
|
if (typeof v !== 'object' || v === null) return false;
|
|
if (!('props' in v)) return false;
|
|
const propsUnknown = (v as { props: unknown }).props;
|
|
if (typeof propsUnknown !== 'object' || propsUnknown === null) return false;
|
|
const props = propsUnknown as Record<string, unknown>;
|
|
return typeof props.href === 'string' && typeof props.name === 'string';
|
|
}
|
|
|
|
// implement as base function
|
|
const TabsBase: FC<TabsProps> = ({
|
|
children,
|
|
value,
|
|
onChange,
|
|
orientation = 'horizontal',
|
|
className = '',
|
|
tabClassName = ''
|
|
}) => {
|
|
const pathname = usePathname()
|
|
|
|
const rawTabs = Children.toArray(children);
|
|
const tabs = rawTabs.filter(isTabElement);
|
|
|
|
const isVertical = orientation === 'vertical'
|
|
const current = normalize(pathname)
|
|
const hrefs = tabs.map(t => normalize(t.props.href))
|
|
|
|
return (
|
|
<nav
|
|
className={[
|
|
'flex',
|
|
isVertical ? 'flex-col gap-y-1' : 'flex-row gap-x-1',
|
|
className
|
|
].join(' ')}
|
|
aria-label="Tabs"
|
|
role="tablist"
|
|
aria-orientation={isVertical ? 'vertical' : 'horizontal'}
|
|
>
|
|
{tabs.map((tab, index) => {
|
|
const baseClasses = 'py-2 px-4 text-sm rounded-lg transition-colors ' + tabClassName
|
|
|
|
if (onChange && value !== undefined) {
|
|
const isActive = value === tab.props.name
|
|
return (
|
|
<button
|
|
key={index}
|
|
type="button"
|
|
onClick={() => onChange(tab.props.name)}
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
className={
|
|
baseClasses + ' ' + (isActive
|
|
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
|
|
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700')
|
|
}
|
|
>
|
|
{tab.props.name}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
const base = normalize(tab.props.href)
|
|
const hasSiblingDeeper = hrefs.some(h => h !== base && h.startsWith(base + '/'))
|
|
const allowStartsWith = !hasSiblingDeeper
|
|
const isActive = current === base || (allowStartsWith && current.startsWith(base + '/'))
|
|
|
|
return (
|
|
<Link
|
|
key={index}
|
|
href={tab.props.href}
|
|
role="tab"
|
|
aria-selected={isActive}
|
|
className={
|
|
baseClasses + ' ' + (isActive
|
|
? 'bg-gray-100 text-gray-900 dark:bg-neutral-700 dark:text-white'
|
|
: 'text-gray-500 hover:bg-gray-100 dark:text-neutral-400 dark:hover:bg-neutral-700')
|
|
}
|
|
>
|
|
{tab.props.name}
|
|
</Link>
|
|
)
|
|
})}
|
|
</nav>
|
|
)
|
|
}
|
|
|
|
// the dummy Tab element (for nicer JSX usage)
|
|
const Tab: FC<TabProps> = () => null
|
|
|
|
// ── export Tabs with a typed static property
|
|
export const Tabs: TabsComponent = Object.assign(TabsBase, { Tab })
|