2025-10-14 15:30:11 +02:00

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 })