mirror of
https://github.com/claude-code-best/claude-code.git
synced 2026-06-15 12:55:51 +00:00
303 lines
11 KiB
TypeScript
303 lines
11 KiB
TypeScript
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<React.ReactElement<TabProps>>;
|
|
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<TabsContextValue>({
|
|
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 (
|
|
<TabsContext.Provider
|
|
value={{
|
|
selectedTab: tabs[selectedTabIndex]![0],
|
|
width: contentWidth,
|
|
headerFocused,
|
|
focusHeader,
|
|
blurHeader,
|
|
registerOptIn,
|
|
}}
|
|
>
|
|
<Box
|
|
flexDirection="column"
|
|
tabIndex={0}
|
|
autoFocus
|
|
onKeyDown={handleKeyDown}
|
|
// flexShrink=0 inside modal slot — the modal's absolute Box has no
|
|
// explicit height (grows to fit, maxHeight cap), so flexGrow=1 here
|
|
// resolves to 0 on re-render and the body blanks on Down arrow.
|
|
// See #23592. Outside modal, leave layout alone.
|
|
flexShrink={modalScrollRef ? 0 : undefined}
|
|
>
|
|
{!hidden && (
|
|
<Box flexDirection="row" gap={1} flexShrink={modalScrollRef ? 0 : undefined}>
|
|
{title !== undefined && (
|
|
<Text bold color={color}>
|
|
{title}
|
|
</Text>
|
|
)}
|
|
{tabs.map(([id, title], i) => {
|
|
const isCurrent = selectedTabIndex === i;
|
|
const hasColorCursor = color && isCurrent && headerFocused;
|
|
return (
|
|
<Text
|
|
key={id}
|
|
backgroundColor={hasColorCursor ? color : undefined}
|
|
color={hasColorCursor ? 'inverseText' : undefined}
|
|
inverse={isCurrent && !hasColorCursor}
|
|
bold={isCurrent}
|
|
>
|
|
{' '}
|
|
{title}{' '}
|
|
</Text>
|
|
);
|
|
})}
|
|
{spacerWidth > 0 && <Text>{' '.repeat(spacerWidth)}</Text>}
|
|
</Box>
|
|
)}
|
|
{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.
|
|
<Box width={contentWidth} marginTop={hidden ? 0 : 1} flexShrink={0}>
|
|
<ScrollBox key={selectedTabIndex} ref={modalScrollRef} flexDirection="column" flexShrink={0}>
|
|
{children}
|
|
</ScrollBox>
|
|
</Box>
|
|
) : (
|
|
<Box
|
|
width={contentWidth}
|
|
marginTop={hidden ? 0 : 1}
|
|
height={contentHeight}
|
|
overflowY={contentHeight !== undefined ? 'hidden' : undefined}
|
|
>
|
|
{children}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
</TabsContext.Provider>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<Box width={width} flexShrink={insideModal ? 0 : undefined}>
|
|
{children}
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
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 };
|
|
}
|