Files
claude-code/packages/@ant/ink/src/theme/Tabs.tsx
2026-05-01 21:55:51 +08:00

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