'use client'; import * as React from 'react'; import { type ComponentProps, createContext, type ReactNode, useContext, useEffect, useId, useMemo, useState, } from 'react'; import { cn } from '../lib/cn'; import * as Unstyled from './ui/tabs'; type CollectionKey = string | symbol; export interface TabsProps extends Omit< ComponentProps, 'value' | 'onValueChange' > { /** * Use simple mode instead of advanced usage as documented in https://radix-ui.com/primitives/docs/components/tabs. */ items?: string[]; /** * Shortcut for `defaultValue` when `items` is provided. * * @defaultValue 0 */ defaultIndex?: number; /** * Additional label in tabs list when `items` is provided. */ label?: ReactNode; } const TabsContext = createContext<{ items?: string[]; collection: CollectionKey[]; } | null>(null); function useTabContext() { const ctx = useContext(TabsContext); if (!ctx) throw new Error('You must wrap your component in '); return ctx; } export function TabsList({ className, ...props }: React.ComponentProps) { return ( cn( 'flex gap-3.5 text-fd-secondary-foreground overflow-x-auto px-4 not-prose', typeof className === 'function' ? className(s) : className, ) } /> ); } export function TabsTrigger({ className, ...props }: React.ComponentProps) { return ( cn( 'inline-flex items-center gap-2 whitespace-nowrap text-fd-muted-foreground border-b border-transparent py-2 text-sm font-medium transition-colors [&_svg]:size-4 hover:text-fd-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[active]:border-fd-primary data-[active]:text-fd-primary', typeof className === 'function' ? className(s) : className, ) } /> ); } export function Tabs({ ref, className, items, label, defaultIndex = 0, defaultValue = items ? escapeValue(items[defaultIndex]) : undefined, ...props }: TabsProps) { const [value, setValue] = useState(defaultValue); const collection = useMemo(() => [], []); return ( cn( 'flex flex-col overflow-hidden rounded-xl border bg-fd-secondary my-4', typeof className === 'function' ? className(s) : className, ) } value={value} onValueChange={(v: string) => { if (items && !items.some((item) => escapeValue(item) === v)) return; setValue(v); }} {...props} > {items && ( {label && {label}} {items.map((item) => ( {item} ))} )} ({ items, collection }), [collection, items])}> {props.children} ); } export interface TabProps extends Omit, 'value'> { /** * Value of tab, detect from index if unspecified. */ value?: string; } export function Tab({ value, ...props }: TabProps) { const { items } = useTabContext(); const resolved = value ?? // eslint-disable-next-line react-hooks/rules-of-hooks -- `value` is not supposed to change items?.at(useCollectionIndex()); if (!resolved) throw new Error( 'Failed to resolve tab `value`, please pass a `value` prop to the Tab component.', ); return ( {props.children} ); } export function TabsContent({ value, className, ...props }: ComponentProps) { return ( cn( 'p-4 text-[0.9375rem] bg-fd-background rounded-xl outline-none prose-no-margin data-[inactive]:hidden [&>figure:only-child]:-m-4 [&>figure:only-child]:border-none', typeof className === 'function' ? className(s) : className, ) } {...props} > {props.children} ); } /** * Inspired by Headless UI. * * Return the index of children, this is made possible by registering the order of render from children using React context. * This is supposed by work with pre-rendering & pure client-side rendering. */ function useCollectionIndex() { const key = useId(); const { collection } = useTabContext(); useEffect(() => { return () => { const idx = collection.indexOf(key); if (idx !== -1) collection.splice(idx, 1); }; }, [key, collection]); if (!collection.includes(key)) collection.push(key); return collection.indexOf(key); } /** * only escape whitespaces in values in simple mode */ function escapeValue(v: string): string { return v.toLowerCase().replace(/\s/, '-'); }