mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-16 00:51:25 -07:00
248 lines
6.8 KiB
TypeScript
248 lines
6.8 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { useReducer, useCallback, useEffect, useRef } from 'react';
|
|
import { useKeypress, type Key } from './useKeypress.js';
|
|
import { keyMatchers, Command } from '../keyMatchers.js';
|
|
|
|
/**
|
|
* Options for the useTabbedNavigation hook.
|
|
*/
|
|
export interface UseTabbedNavigationOptions {
|
|
/** Total number of tabs */
|
|
tabCount: number;
|
|
/** Initial tab index (default: 0) */
|
|
initialIndex?: number;
|
|
/** Allow wrapping from last to first and vice versa (default: false) */
|
|
wrapAround?: boolean;
|
|
/** Whether left/right arrows navigate tabs (default: true) */
|
|
enableArrowNavigation?: boolean;
|
|
/** Whether Tab key advances to next tab (default: true) */
|
|
enableTabKey?: boolean;
|
|
/** Callback to determine if navigation is blocked (e.g., during text input) */
|
|
isNavigationBlocked?: () => boolean;
|
|
/** Whether the hook is active and should respond to keyboard input */
|
|
isActive?: boolean;
|
|
/** Callback when the active tab changes */
|
|
onTabChange?: (index: number) => void;
|
|
}
|
|
|
|
/**
|
|
* Result of the useTabbedNavigation hook.
|
|
*/
|
|
export interface UseTabbedNavigationResult {
|
|
/** Current tab index */
|
|
currentIndex: number;
|
|
/** Set the current tab index directly */
|
|
setCurrentIndex: (index: number) => void;
|
|
/** Move to the next tab (respecting bounds) */
|
|
goToNextTab: () => void;
|
|
/** Move to the previous tab (respecting bounds) */
|
|
goToPrevTab: () => void;
|
|
/** Whether currently at first tab */
|
|
isFirstTab: boolean;
|
|
/** Whether currently at last tab */
|
|
isLastTab: boolean;
|
|
}
|
|
|
|
interface TabbedNavigationState {
|
|
currentIndex: number;
|
|
tabCount: number;
|
|
wrapAround: boolean;
|
|
pendingTabChange: boolean;
|
|
}
|
|
|
|
type TabbedNavigationAction =
|
|
| { type: 'NEXT_TAB' }
|
|
| { type: 'PREV_TAB' }
|
|
| { type: 'SET_INDEX'; payload: { index: number } }
|
|
| {
|
|
type: 'INITIALIZE';
|
|
payload: { tabCount: number; initialIndex: number; wrapAround: boolean };
|
|
}
|
|
| { type: 'CLEAR_PENDING' };
|
|
|
|
function tabbedNavigationReducer(
|
|
state: TabbedNavigationState,
|
|
action: TabbedNavigationAction,
|
|
): TabbedNavigationState {
|
|
switch (action.type) {
|
|
case 'NEXT_TAB': {
|
|
const { tabCount, wrapAround, currentIndex } = state;
|
|
if (tabCount === 0) return state;
|
|
|
|
let nextIndex = currentIndex + 1;
|
|
if (nextIndex >= tabCount) {
|
|
nextIndex = wrapAround ? 0 : tabCount - 1;
|
|
}
|
|
|
|
if (nextIndex === currentIndex) return state;
|
|
return { ...state, currentIndex: nextIndex, pendingTabChange: true };
|
|
}
|
|
|
|
case 'PREV_TAB': {
|
|
const { tabCount, wrapAround, currentIndex } = state;
|
|
if (tabCount === 0) return state;
|
|
|
|
let nextIndex = currentIndex - 1;
|
|
if (nextIndex < 0) {
|
|
nextIndex = wrapAround ? tabCount - 1 : 0;
|
|
}
|
|
|
|
if (nextIndex === currentIndex) return state;
|
|
return { ...state, currentIndex: nextIndex, pendingTabChange: true };
|
|
}
|
|
|
|
case 'SET_INDEX': {
|
|
const { index } = action.payload;
|
|
const { tabCount, currentIndex } = state;
|
|
|
|
if (index === currentIndex) return state;
|
|
if (index < 0 || index >= tabCount) return state;
|
|
|
|
return { ...state, currentIndex: index, pendingTabChange: true };
|
|
}
|
|
|
|
case 'INITIALIZE': {
|
|
const { tabCount, initialIndex, wrapAround } = action.payload;
|
|
const validIndex = Math.max(0, Math.min(initialIndex, tabCount - 1));
|
|
return {
|
|
...state,
|
|
tabCount,
|
|
wrapAround,
|
|
currentIndex: tabCount > 0 ? validIndex : 0,
|
|
pendingTabChange: false,
|
|
};
|
|
}
|
|
|
|
case 'CLEAR_PENDING': {
|
|
return { ...state, pendingTabChange: false };
|
|
}
|
|
|
|
default: {
|
|
return state;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A headless hook that provides keyboard navigation for tabbed interfaces.
|
|
*
|
|
* Features:
|
|
* - Keyboard navigation with left/right arrows
|
|
* - Optional Tab key navigation
|
|
* - Optional wrap-around navigation
|
|
* - Navigation blocking callback (for text input scenarios)
|
|
*/
|
|
export function useTabbedNavigation({
|
|
tabCount,
|
|
initialIndex = 0,
|
|
wrapAround = false,
|
|
enableArrowNavigation = true,
|
|
enableTabKey = true,
|
|
isNavigationBlocked,
|
|
isActive = true,
|
|
onTabChange,
|
|
}: UseTabbedNavigationOptions): UseTabbedNavigationResult {
|
|
const [state, dispatch] = useReducer(tabbedNavigationReducer, {
|
|
currentIndex: Math.max(0, Math.min(initialIndex, tabCount - 1)),
|
|
tabCount,
|
|
wrapAround,
|
|
pendingTabChange: false,
|
|
});
|
|
|
|
const prevTabCountRef = useRef(tabCount);
|
|
const prevInitialIndexRef = useRef(initialIndex);
|
|
const prevWrapAroundRef = useRef(wrapAround);
|
|
|
|
useEffect(() => {
|
|
const tabCountChanged = prevTabCountRef.current !== tabCount;
|
|
const initialIndexChanged = prevInitialIndexRef.current !== initialIndex;
|
|
const wrapAroundChanged = prevWrapAroundRef.current !== wrapAround;
|
|
|
|
if (tabCountChanged || initialIndexChanged || wrapAroundChanged) {
|
|
dispatch({
|
|
type: 'INITIALIZE',
|
|
payload: { tabCount, initialIndex, wrapAround },
|
|
});
|
|
prevTabCountRef.current = tabCount;
|
|
prevInitialIndexRef.current = initialIndex;
|
|
prevWrapAroundRef.current = wrapAround;
|
|
}
|
|
}, [tabCount, initialIndex, wrapAround]);
|
|
|
|
useEffect(() => {
|
|
if (state.pendingTabChange) {
|
|
onTabChange?.(state.currentIndex);
|
|
dispatch({ type: 'CLEAR_PENDING' });
|
|
}
|
|
}, [state.pendingTabChange, state.currentIndex, onTabChange]);
|
|
|
|
const goToNextTab = useCallback(() => {
|
|
if (isNavigationBlocked?.()) return;
|
|
dispatch({ type: 'NEXT_TAB' });
|
|
}, [isNavigationBlocked]);
|
|
|
|
const goToPrevTab = useCallback(() => {
|
|
if (isNavigationBlocked?.()) return;
|
|
dispatch({ type: 'PREV_TAB' });
|
|
}, [isNavigationBlocked]);
|
|
|
|
const setCurrentIndex = useCallback(
|
|
(index: number) => {
|
|
if (isNavigationBlocked?.()) return;
|
|
dispatch({ type: 'SET_INDEX', payload: { index } });
|
|
},
|
|
[isNavigationBlocked],
|
|
);
|
|
|
|
const handleKeypress = useCallback(
|
|
(key: Key) => {
|
|
if (isNavigationBlocked?.()) return;
|
|
|
|
if (enableArrowNavigation) {
|
|
if (keyMatchers[Command.MOVE_RIGHT](key)) {
|
|
goToNextTab();
|
|
return;
|
|
}
|
|
if (keyMatchers[Command.MOVE_LEFT](key)) {
|
|
goToPrevTab();
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (enableTabKey) {
|
|
if (keyMatchers[Command.DIALOG_NEXT](key)) {
|
|
goToNextTab();
|
|
return;
|
|
}
|
|
if (keyMatchers[Command.DIALOG_PREV](key)) {
|
|
goToPrevTab();
|
|
return;
|
|
}
|
|
}
|
|
},
|
|
[
|
|
enableArrowNavigation,
|
|
enableTabKey,
|
|
goToNextTab,
|
|
goToPrevTab,
|
|
isNavigationBlocked,
|
|
],
|
|
);
|
|
|
|
useKeypress(handleKeypress, { isActive: isActive && tabCount > 1 });
|
|
|
|
return {
|
|
currentIndex: state.currentIndex,
|
|
setCurrentIndex,
|
|
goToNextTab,
|
|
goToPrevTab,
|
|
isFirstTab: state.currentIndex === 0,
|
|
isLastTab: state.currentIndex === tabCount - 1,
|
|
};
|
|
}
|