import React, { createContext, useCallback, useContext, useEffect, useState, } from 'react' import { useIsInsideModal, useModalScrollRef, } from './modalContext.js' import { useTerminalSize } from '../hooks/useTerminalSize.js' import ScrollBox from '../components/ScrollBox.js' import type { KeyboardEvent } from '../core/events/keyboard-event.js' import { stringWidth } from '../core/stringWidth.js' import { Box, Text } from '../index.js' import { useKeybindings } from '../keybindings/useKeybinding.js' import type { Theme } from './theme-types.js' type TabsProps = { children: Array> title?: string color?: keyof Theme defaultTab?: string hidden?: boolean useFullWidth?: boolean /** Controlled mode: current selected tab id/title */ selectedTab?: string /** Controlled mode: callback when tab changes */ onTabChange?: (tabId: string) => void /** Optional banner to display below tabs header */ banner?: React.ReactNode /** Disable keyboard navigation (e.g. when a child component handles arrow keys) */ disableNavigation?: boolean /** * Initial focus state for the tab header row. Defaults to true (header * focused, nav always works). Keep the default for Select/list content — * those only use up/down so there's no conflict; pass * isDisabled={headerFocused} to the Select instead. Only set false when * content actually binds left/right/tab (e.g. enum cycling), and show a * "↑ tabs" footer hint — without it tabs look broken. */ initialHeaderFocused?: boolean /** * Fixed height for the content area. When set, all tabs render within the * same height (overflow hidden) so switching tabs doesn't cause layout * shifts. Shorter tabs get whitespace; taller tabs are clipped. */ contentHeight?: number /** * Let Tab/←/→ switch tabs from focused content. Opt-in since some * content uses those keys; pass a reactive boolean to cede them when * needed. Switching from content focuses the header. */ navFromContent?: boolean } type TabsContextValue = { selectedTab: string | undefined width: number | undefined headerFocused: boolean focusHeader: () => void blurHeader: () => void registerOptIn: () => () => void } const TabsContext = createContext({ selectedTab: undefined, width: undefined, // Default for components rendered outside a Tabs (tests, standalone): // content has focus, focusHeader is a no-op. headerFocused: false, focusHeader: () => {}, blurHeader: () => {}, registerOptIn: () => () => {}, }) export function Tabs({ title, color, defaultTab, children, hidden, useFullWidth, selectedTab: controlledSelectedTab, onTabChange, banner, disableNavigation, initialHeaderFocused = true, contentHeight, navFromContent = false, }: TabsProps): React.ReactNode { const { columns: terminalWidth } = useTerminalSize() const tabs = children.map(child => [ child.props.id ?? child.props.title, child.props.title, ]) const defaultTabIndex = defaultTab ? tabs.findIndex(tab => defaultTab === tab[0]) : 0 // Support both controlled and uncontrolled modes const isControlled = controlledSelectedTab !== undefined const [internalSelectedTab, setInternalSelectedTab] = useState( defaultTabIndex !== -1 ? defaultTabIndex : 0, ) // In controlled mode, find the index of the controlled tab const controlledTabIndex = isControlled ? tabs.findIndex(tab => tab[0] === controlledSelectedTab) : -1 const selectedTabIndex = isControlled ? controlledTabIndex !== -1 ? controlledTabIndex : 0 : internalSelectedTab const modalScrollRef = useModalScrollRef() // Header focus: left/right/tab only switch tabs when the header row is // focused. Children with interactive content call focusHeader() (via // useTabHeaderFocus) on up-arrow to hand focus back here; down-arrow // returns it. Tabs that never call the hook see no behavior change — // initialHeaderFocused defaults to true so nav always works. const [headerFocused, setHeaderFocused] = useState(initialHeaderFocused) const focusHeader = useCallback(() => setHeaderFocused(true), []) const blurHeader = useCallback(() => setHeaderFocused(false), []) // Count of mounted children using useTabHeaderFocus(). Down-arrow blur and // the ↓ hint only engage when at least one child has opted in — otherwise // pressing down on a legacy tab would strand the user with nav disabled. const [optInCount, setOptInCount] = useState(0) const registerOptIn = useCallback(() => { setOptInCount(n => n + 1) return () => setOptInCount(n => n - 1) }, []) const optedIn = optInCount > 0 const handleTabChange = (offset: number) => { const newIndex = (selectedTabIndex + tabs.length + offset) % tabs.length const newTabId = tabs[newIndex]?.[0] if (isControlled && onTabChange && newTabId) { onTabChange(newTabId) } else { setInternalSelectedTab(newIndex) } // Tab switching is a header action — stay focused so the user can keep // cycling. The newly mounted tab can blur via its own interaction. setHeaderFocused(true) } useKeybindings( { 'tabs:next': () => handleTabChange(1), 'tabs:previous': () => handleTabChange(-1), }, { context: 'Tabs', isActive: !hidden && !disableNavigation && headerFocused, }, ) // When the header is focused, down-arrow returns focus to content. Only // active when the selected tab has opted in via useTabHeaderFocus() — // legacy tabs have nowhere to return focus to. const handleKeyDown = (e: KeyboardEvent) => { if (!headerFocused || !optedIn || hidden) return if (e.key === 'down') { e.preventDefault() setHeaderFocused(false) } } // Opt-in: same tabs:next/previous actions, active from content. Focuses // the header so subsequent presses cycle via the handler above. useKeybindings( { 'tabs:next': () => { handleTabChange(1) setHeaderFocused(true) }, 'tabs:previous': () => { handleTabChange(-1) setHeaderFocused(true) }, }, { context: 'Tabs', isActive: navFromContent && !headerFocused && optedIn && !hidden && !disableNavigation, }, ) // Calculate spacing to fill the available width. No keyboard hint in the // header row — content footers own hints (see useTabHeaderFocus docs). const titleWidth = title ? stringWidth(title) + 1 : 0 // +1 for gap const tabsWidth = tabs.reduce( (sum, [, tabTitle]) => sum + (tabTitle ? stringWidth(tabTitle) : 0) + 2 + 1, // +2 for padding, +1 for gap 0, ) const usedWidth = titleWidth + tabsWidth const spacerWidth = useFullWidth ? Math.max(0, terminalWidth - usedWidth) : 0 const contentWidth = useFullWidth ? terminalWidth : undefined return ( {!hidden && ( {title !== undefined && ( {title} )} {tabs.map(([id, title], i) => { const isCurrent = selectedTabIndex === i const hasColorCursor = color && isCurrent && headerFocused return ( {' '} {title}{' '} ) })} {spacerWidth > 0 && {' '.repeat(spacerWidth)}} )} {banner} {modalScrollRef ? ( // Inside the modal slot: own the ScrollBox here so the tabs // header row above sits OUTSIDE the scroll area — it can never // scroll off. The ref reaches REPL's ScrollKeybindingHandler via // ModalContext. Keyed by selectedTabIndex → remounts on tab // switch, resetting scrollTop to 0 without scrollTo() timing games. {children} ) : ( {children} )} ) } type TabProps = { title: string id?: string children: React.ReactNode } export function Tab({ title, id, children }: TabProps): React.ReactNode { const { selectedTab, width } = useContext(TabsContext) const insideModal = useIsInsideModal() if (selectedTab !== (id ?? title)) { return null } return ( {children} ) } export function useTabsWidth(): number | undefined { const { width } = useContext(TabsContext) return width } /** * Opt into header-focus gating. Returns the current header focus state and a * callback to hand focus back to the tab row. For a Select, pass * `isDisabled={headerFocused}` and `onUpFromFirstItem={focusHeader}`; keep the * parent Tabs' initialHeaderFocused at its default so tab/←/→ work on mount. * * Calling this hook registers a ↓-blurs-header opt-in on mount. Don't call it * above an early return that renders static text — ↓ will blur the header with * no onUpFromFirstItem to recover. Split the component so the hook only runs * when the Select renders. */ export function useTabHeaderFocus(): { headerFocused: boolean focusHeader: () => void blurHeader: () => void } { const { headerFocused, focusHeader, blurHeader, registerOptIn } = useContext(TabsContext) useEffect(registerOptIn, [registerOptIn]) return { headerFocused, focusHeader, blurHeader } }