import React, { forwardRef, useCallback, useEffect, useId, useMemo, useRef, useState, } from 'react'; import { Surface, TabItem, cn, useEffectiveSurface } from '../common'; import { useControllableState } from '../hooks/useControllableState'; import { PixelTabsContext, PixelTabsContextValue, TabsActivationMode, TabsOrientation, } from './_internal/tabsContext'; import { PixelTabsList } from './PixelTabsList'; import { PixelTabsTrigger } from './PixelTabsTrigger'; import { PixelTabsPanel } from './PixelTabsPanel'; /* ───────────────────────────────────────────────────────────────────────── PixelTabs — tabbed panel with roving tabindex + keyboard nav. Sugar API (items[]) + compositional API (List/Trigger/Panel). ───────────────────────────────────────────────────────────────────────── */ /** Public prop bag for {@link PixelTabs}. */ export interface PixelTabsProps { /** Sugar API. When omitted, use //. */ items?: TabItem[]; /** Uncontrolled initial active tab id. Canonical name; aliases `defaultTab`. */ defaultValue?: string; /** * @deprecated Use `defaultValue` instead. Retained as alias for one minor. */ defaultTab?: string; /** Controlled active tab id. When set, `defaultValue` is ignored. */ value?: string; /** Fires whenever the active tab changes (controlled or uncontrolled). */ onChange?: (id: string) => void; /** Visual surface treatment override. */ surface?: Surface; /** Accessible label for the tablist. */ ariaLabel?: string; /** Layout direction for the tab list + keyboard nav. */ orientation?: TabsOrientation; /** When true, all panels render in the DOM (hidden via CSS). */ keepMounted?: boolean; /** When true, the tablist scrolls horizontally with a fade-mask. Ignored for vertical orientation. */ scrollable?: boolean; /** 'automatic' (default) selects on focus. 'manual' requires Enter/Space. */ activationMode?: TabsActivationMode; /** Compositional children. Ignored when `items` is provided. */ children?: React.ReactNode; } function PixelTabsRoot( { items, defaultValue, defaultTab, value, onChange, surface: surfaceProp, ariaLabel = 'Tabs', orientation = 'horizontal', keepMounted = false, scrollable = false, activationMode = 'automatic', children, }: PixelTabsProps, ref: React.Ref, ) { const surface = useEffectiveSurface(surfaceProp); const sugar = items && items.length > 0; const seedDefault = defaultValue ?? defaultTab ?? items?.[0]?.id; const [active, setActive] = useControllableState({ value, defaultValue: seedDefault, onChange: onChange ? ((next) => { if (next !== undefined) onChange(next); }) : undefined, }); // Track sync changes for auto-seed compat below const isControlled = value !== undefined; const internal = isControlled ? undefined : active; const setInternal = (id: string) => setActive(id); const baseId = useId(); const triggerRefs = useRef>(new Map()); const triggerOrder = useRef([]); const select = useCallback( (id: string) => { setActive(id); }, [setActive], ); const registerTrigger = useCallback((id: string, el: HTMLButtonElement | null) => { triggerRefs.current.set(id, el); if (el && !triggerOrder.current.includes(id)) { triggerOrder.current.push(id); } // Auto-seed: compositional API with no defaultTab/value → activate the first // registered trigger so a panel renders + tablist is keyboard-reachable. if (el && !sugar && !isControlled && internal === undefined && triggerOrder.current[0] === id) { setInternal(id); } }, [sugar, isControlled, internal]); const unregisterTrigger = useCallback((id: string) => { triggerRefs.current.delete(id); triggerOrder.current = triggerOrder.current.filter((x) => x !== id); }, []); const focusTriggerById = useCallback((id: string) => { triggerRefs.current.get(id)?.focus(); }, []); const focusByOffset = useCallback((currentId: string, offset: number) => { const order = triggerOrder.current; const len = order.length; if (!len) return; const idx = order.indexOf(currentId); const fromIdx = idx === -1 ? 0 : idx; const nextIdx = ((fromIdx + offset) % len + len) % len; const nextId = order[nextIdx]; triggerRefs.current.get(nextId)?.focus(); }, []); const focusEdge = useCallback((edge: 'first' | 'last') => { const order = triggerOrder.current; if (!order.length) return; const id = edge === 'first' ? order[0] : order[order.length - 1]; triggerRefs.current.get(id)?.focus(); }, []); // Pre-register sugar items so triggerOrder reflects them even before mount. useEffect(() => { if (!sugar) return; triggerOrder.current = items!.map((i) => i.id); }, [sugar, items]); const ctx = useMemo( () => ({ baseId, active, select, registerTrigger, unregisterTrigger, triggerOrder, focusTriggerById, focusByOffset, focusEdge, orientation, activationMode, keepMounted, surface, }), [ baseId, active, select, registerTrigger, unregisterTrigger, focusTriggerById, focusByOffset, focusEdge, orientation, activationMode, keepMounted, surface, ], ); return (
{sugar ? ( <> {items!.map((item) => ( {item.label} ))}
{items!.map((item) => ( {item.content} ))}
) : ( children )}
); } const PixelTabsRootFwd = forwardRef(PixelTabsRoot); PixelTabsRootFwd.displayName = 'PixelTabs'; type PixelTabsComponent = typeof PixelTabsRootFwd & { List: typeof PixelTabsList; Trigger: typeof PixelTabsTrigger; Panel: typeof PixelTabsPanel; }; export const PixelTabs = PixelTabsRootFwd as PixelTabsComponent; PixelTabs.List = PixelTabsList; PixelTabs.Trigger = PixelTabsTrigger; PixelTabs.Panel = PixelTabsPanel;