mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 06:31:01 -07:00
feat: add AskUserDialog for UI component of AskUser tool (#17344)
Co-authored-by: jacob314 <jacob314@gmail.com>
This commit is contained in:
102
packages/cli/examples/ask-user-dialog-demo.tsx
Normal file
102
packages/cli/examples/ask-user-dialog-demo.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { render, Box, Text } from 'ink';
|
||||
import { AskUserDialog } from '../src/ui/components/AskUserDialog.js';
|
||||
import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js';
|
||||
import { QuestionType, type Question } from '@google/gemini-cli-core';
|
||||
|
||||
const DEMO_QUESTIONS: Question[] = [
|
||||
{
|
||||
question: 'What type of project are you building?',
|
||||
header: 'Project Type',
|
||||
options: [
|
||||
{ label: 'Web Application', description: 'React, Next.js, or similar' },
|
||||
{ label: 'CLI Tool', description: 'Command-line interface with Node.js' },
|
||||
{ label: 'Library', description: 'NPM package or shared utility' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Which features should be enabled?',
|
||||
header: 'Features',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: 'Add static typing' },
|
||||
{ label: 'ESLint', description: 'Add linting and formatting' },
|
||||
{ label: 'Unit Tests', description: 'Add Vitest setup' },
|
||||
{ label: 'CI/CD', description: 'Add GitHub Actions' },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
{
|
||||
question: 'What is the project name?',
|
||||
header: 'Name',
|
||||
type: QuestionType.TEXT,
|
||||
placeholder: 'my-awesome-project',
|
||||
},
|
||||
{
|
||||
question: 'Initialize git repository?',
|
||||
header: 'Git',
|
||||
type: QuestionType.YESNO,
|
||||
},
|
||||
];
|
||||
|
||||
const Demo = () => {
|
||||
const [result, setResult] = useState<null | { [key: string]: string }>(null);
|
||||
const [cancelled, setCancelled] = useState(false);
|
||||
|
||||
if (cancelled) {
|
||||
return (
|
||||
<Box padding={1}>
|
||||
<Text color="red">
|
||||
Dialog was cancelled. Project initialization aborted.
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (result) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
padding={1}
|
||||
borderStyle="single"
|
||||
borderColor="green"
|
||||
>
|
||||
<Text bold color="green">
|
||||
Success! Project Configuration:
|
||||
</Text>
|
||||
{DEMO_QUESTIONS.map((q, i) => (
|
||||
<Box key={i} marginTop={1}>
|
||||
<Text color="gray">{q.header}: </Text>
|
||||
<Text>{result[i] || '(not answered)'}</Text>
|
||||
</Box>
|
||||
))}
|
||||
<Box marginTop={1}>
|
||||
<Text color="dim">Press Ctrl+C to exit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<KeypressProvider>
|
||||
<Box padding={1} flexDirection="column">
|
||||
<Text bold marginBottom={1}>
|
||||
AskUserDialog Demo
|
||||
</Text>
|
||||
<AskUserDialog
|
||||
questions={DEMO_QUESTIONS}
|
||||
onSubmit={setResult}
|
||||
onCancel={() => setCancelled(true)}
|
||||
/>
|
||||
</Box>
|
||||
</KeypressProvider>
|
||||
);
|
||||
};
|
||||
|
||||
render(<Demo />);
|
||||
855
packages/cli/src/ui/components/AskUserDialog.test.tsx
Normal file
855
packages/cli/src/ui/components/AskUserDialog.test.tsx
Normal file
@@ -0,0 +1,855 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { AskUserDialog } from './AskUserDialog.js';
|
||||
import { QuestionType, type Question } from '@google/gemini-cli-core';
|
||||
|
||||
// Helper to write to stdin with proper act() wrapping
|
||||
const writeKey = (stdin: { write: (data: string) => void }, key: string) => {
|
||||
act(() => {
|
||||
stdin.write(key);
|
||||
});
|
||||
};
|
||||
|
||||
describe('AskUserDialog', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const authQuestion: Question[] = [
|
||||
{
|
||||
question: 'Which authentication method should we use?',
|
||||
header: 'Auth',
|
||||
options: [
|
||||
{ label: 'OAuth 2.0', description: 'Industry standard, supports SSO' },
|
||||
{ label: 'JWT tokens', description: 'Stateless, good for APIs' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
it('renders question and options', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: 'Single Select',
|
||||
questions: authQuestion,
|
||||
actions: (stdin: { write: (data: string) => void }) => {
|
||||
writeKey(stdin, '\r');
|
||||
},
|
||||
expectedSubmit: { '0': 'OAuth 2.0' },
|
||||
},
|
||||
{
|
||||
name: 'Multi-select',
|
||||
questions: [
|
||||
{
|
||||
question: 'Which features?',
|
||||
header: 'Features',
|
||||
options: [
|
||||
{ label: 'TypeScript', description: '' },
|
||||
{ label: 'ESLint', description: '' },
|
||||
],
|
||||
multiSelect: true,
|
||||
},
|
||||
] as Question[],
|
||||
actions: (stdin: { write: (data: string) => void }) => {
|
||||
writeKey(stdin, '\r'); // Toggle TS
|
||||
writeKey(stdin, '\x1b[B'); // Down
|
||||
writeKey(stdin, '\r'); // Toggle ESLint
|
||||
writeKey(stdin, '\x1b[B'); // Down to Other
|
||||
writeKey(stdin, '\x1b[B'); // Down to Done
|
||||
writeKey(stdin, '\r'); // Done
|
||||
},
|
||||
expectedSubmit: { '0': 'TypeScript, ESLint' },
|
||||
},
|
||||
{
|
||||
name: 'Text Input',
|
||||
questions: [
|
||||
{
|
||||
question: 'Name?',
|
||||
header: 'Name',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
] as Question[],
|
||||
actions: (stdin: { write: (data: string) => void }) => {
|
||||
for (const char of 'test-app') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
writeKey(stdin, '\r');
|
||||
},
|
||||
expectedSubmit: { '0': 'test-app' },
|
||||
},
|
||||
])('Submission: $name', ({ name, questions, actions, expectedSubmit }) => {
|
||||
it(`submits correct values for ${name}`, async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={questions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
actions(stdin);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith(expectedSubmit);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('handles custom option in single select with inline typing', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestion}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Move down to custom option
|
||||
writeKey(stdin, '\x1b[B');
|
||||
writeKey(stdin, '\x1b[B');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Enter a custom value');
|
||||
});
|
||||
|
||||
// Type directly (inline)
|
||||
for (const char of 'API Key') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('API Key');
|
||||
});
|
||||
|
||||
// Press Enter to submit the custom value
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ '0': 'API Key' });
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates to custom option when typing unbound characters (Type-to-Jump)', async () => {
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Type a character without navigating down
|
||||
writeKey(stdin, 'A');
|
||||
|
||||
await waitFor(() => {
|
||||
// Should show the custom input with 'A'
|
||||
// Placeholder is hidden when text is present
|
||||
expect(lastFrame()).toContain('A');
|
||||
expect(lastFrame()).toContain('3. A');
|
||||
});
|
||||
|
||||
// Continue typing
|
||||
writeKey(stdin, 'P');
|
||||
writeKey(stdin, 'I');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('API');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows progress header for multiple questions', () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Which database should we use?',
|
||||
header: 'Database',
|
||||
options: [
|
||||
{ label: 'PostgreSQL', description: 'Relational database' },
|
||||
{ label: 'MongoDB', description: 'Document database' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Which ORM do you prefer?',
|
||||
header: 'ORM',
|
||||
options: [
|
||||
{ label: 'Prisma', description: 'Type-safe ORM' },
|
||||
{ label: 'Drizzle', description: 'Lightweight ORM' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('hides progress header for single question', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows keyboard hints', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={authQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('navigates between questions with arrow keys', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Which testing framework?',
|
||||
header: 'Testing',
|
||||
options: [{ label: 'Vitest', description: 'Fast unit testing' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Which CI provider?',
|
||||
header: 'CI',
|
||||
options: [
|
||||
{ label: 'GitHub Actions', description: 'Built into GitHub' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Which testing framework?');
|
||||
|
||||
writeKey(stdin, '\x1b[C'); // Right arrow
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Which CI provider?');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b[D'); // Left arrow
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Which testing framework?');
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves answers when navigating back', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Which package manager?',
|
||||
header: 'Package',
|
||||
options: [{ label: 'pnpm', description: 'Fast, disk efficient' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Which bundler?',
|
||||
header: 'Bundler',
|
||||
options: [{ label: 'Vite', description: 'Next generation bundler' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Answer first question (should auto-advance)
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Which bundler?');
|
||||
});
|
||||
|
||||
// Navigate back
|
||||
writeKey(stdin, '\x1b[D');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Which package manager?');
|
||||
});
|
||||
|
||||
// Navigate forward
|
||||
writeKey(stdin, '\x1b[C');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Which bundler?');
|
||||
});
|
||||
|
||||
// Answer second question
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Review your answers:');
|
||||
});
|
||||
|
||||
// Submit from Review
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ '0': 'pnpm', '1': 'Vite' });
|
||||
});
|
||||
});
|
||||
|
||||
it('shows Review tab in progress header for multiple questions', () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Which framework?',
|
||||
header: 'Framework',
|
||||
options: [
|
||||
{ label: 'React', description: 'Component library' },
|
||||
{ label: 'Vue', description: 'Progressive framework' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Which styling?',
|
||||
header: 'Styling',
|
||||
options: [
|
||||
{ label: 'Tailwind', description: 'Utility-first CSS' },
|
||||
{ label: 'CSS Modules', description: 'Scoped styles' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('allows navigating to Review tab and back', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Create tests?',
|
||||
header: 'Tests',
|
||||
options: [{ label: 'Yes', description: 'Generate test files' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Add documentation?',
|
||||
header: 'Docs',
|
||||
options: [{ label: 'Yes', description: 'Generate JSDoc comments' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
writeKey(stdin, '\x1b[C'); // Right arrow
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add documentation?');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b[C'); // Right arrow to Review
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b[D'); // Left arrow back
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Add documentation?');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows warning for unanswered questions on Review tab', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Which license?',
|
||||
header: 'License',
|
||||
options: [{ label: 'MIT', description: 'Permissive license' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Include README?',
|
||||
header: 'README',
|
||||
options: [{ label: 'Yes', description: 'Generate README.md' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Navigate directly to Review tab without answering
|
||||
writeKey(stdin, '\x1b[C');
|
||||
writeKey(stdin, '\x1b[C');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
||||
it('submits with unanswered questions when user confirms on Review', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Target Node version?',
|
||||
header: 'Node',
|
||||
options: [{ label: 'Node 20', description: 'LTS version' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Enable strict mode?',
|
||||
header: 'Strict',
|
||||
options: [{ label: 'Yes', description: 'Strict TypeScript' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Answer only first question
|
||||
writeKey(stdin, '\r');
|
||||
// Navigate to Review tab
|
||||
writeKey(stdin, '\x1b[C');
|
||||
// Submit
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({ '0': 'Node 20' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('Text type questions', () => {
|
||||
it('renders text input for type: "text"', () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'What should we name this component?',
|
||||
header: 'Name',
|
||||
type: QuestionType.TEXT,
|
||||
placeholder: 'e.g., UserProfileCard',
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('shows default placeholder when none provided', () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the database connection string:',
|
||||
header: 'Database',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('supports backspace in text mode', async () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the function name:',
|
||||
header: 'Function',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const char of 'abc') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('abc');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x7f'); // Backspace
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('ab');
|
||||
expect(lastFrame()).not.toContain('abc');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows correct keyboard hints for text type', () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the variable name:',
|
||||
header: 'Variable',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('preserves text answer when navigating between questions', async () => {
|
||||
const mixedQuestions: Question[] = [
|
||||
{
|
||||
question: 'What should we name this hook?',
|
||||
header: 'Hook',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
{
|
||||
question: 'Should it be async?',
|
||||
header: 'Async',
|
||||
options: [
|
||||
{ label: 'Yes', description: 'Use async/await' },
|
||||
{ label: 'No', description: 'Synchronous hook' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={mixedQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const char of 'useAuth') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
writeKey(stdin, '\t'); // Use Tab instead of Right arrow when text input is active
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Should it be async?');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b[D'); // Left arrow should work when NOT focusing a text input
|
||||
// Wait, Async question is a CHOICE question, so Left arrow SHOULD work.
|
||||
// But ChoiceQuestionView also captures editing custom option state?
|
||||
// No, only if it is FOCUSING the custom option.
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('useAuth');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles mixed text and choice questions', async () => {
|
||||
const mixedQuestions: Question[] = [
|
||||
{
|
||||
question: 'What should we name this component?',
|
||||
header: 'Name',
|
||||
type: QuestionType.TEXT,
|
||||
placeholder: 'Enter component name',
|
||||
},
|
||||
{
|
||||
question: 'Which styling approach?',
|
||||
header: 'Style',
|
||||
options: [
|
||||
{ label: 'CSS Modules', description: 'Scoped CSS' },
|
||||
{ label: 'Tailwind', description: 'Utility classes' },
|
||||
],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={mixedQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const char of 'DataTable') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Which styling approach?');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Review your answers:');
|
||||
expect(lastFrame()).toContain('Name');
|
||||
expect(lastFrame()).toContain('DataTable');
|
||||
expect(lastFrame()).toContain('Style');
|
||||
expect(lastFrame()).toContain('CSS Modules');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
'0': 'DataTable',
|
||||
'1': 'CSS Modules',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('does not submit empty text', () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the class name:',
|
||||
header: 'Class',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={textQuestion}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
writeKey(stdin, '\r');
|
||||
|
||||
// onSubmit should not be called for empty text
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears text on Ctrl+C', async () => {
|
||||
const textQuestion: Question[] = [
|
||||
{
|
||||
question: 'Enter the class name:',
|
||||
header: 'Class',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
const onCancel = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={textQuestion}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={onCancel}
|
||||
/>,
|
||||
);
|
||||
|
||||
for (const char of 'SomeText') {
|
||||
writeKey(stdin, char);
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('SomeText');
|
||||
});
|
||||
|
||||
// Send Ctrl+C
|
||||
writeKey(stdin, '\x03'); // Ctrl+C
|
||||
|
||||
await waitFor(() => {
|
||||
// Text should be cleared
|
||||
expect(lastFrame()).not.toContain('SomeText');
|
||||
expect(lastFrame()).toContain('>');
|
||||
});
|
||||
|
||||
// Should NOT call onCancel (dialog should stay open)
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows immediate arrow navigation after switching away from text input', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Choice Q?',
|
||||
header: 'Choice',
|
||||
options: [{ label: 'Option 1', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Text Q?',
|
||||
header: 'Text',
|
||||
type: QuestionType.TEXT,
|
||||
},
|
||||
];
|
||||
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={vi.fn()}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 1. Move to Text Q (Right arrow works for Choice Q)
|
||||
writeKey(stdin, '\x1b[C');
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Text Q?');
|
||||
});
|
||||
|
||||
// 2. Type something in Text Q to make isEditingCustomOption true
|
||||
writeKey(stdin, 'a');
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('a');
|
||||
});
|
||||
|
||||
// 3. Move back to Choice Q (Left arrow works because cursor is at left edge)
|
||||
// When typing 'a', cursor is at index 1.
|
||||
// We need to move cursor to index 0 first for Left arrow to work for navigation.
|
||||
writeKey(stdin, '\x1b[D'); // Left arrow moves cursor to index 0
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Text Q?');
|
||||
});
|
||||
|
||||
writeKey(stdin, '\x1b[D'); // Second Left arrow should now trigger navigation
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Choice Q?');
|
||||
});
|
||||
|
||||
// 4. Immediately try Right arrow to go back to Text Q
|
||||
writeKey(stdin, '\x1b[C');
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Text Q?');
|
||||
});
|
||||
});
|
||||
|
||||
it('handles rapid sequential answers correctly (stale closure protection)', async () => {
|
||||
const multiQuestions: Question[] = [
|
||||
{
|
||||
question: 'Question 1?',
|
||||
header: 'Q1',
|
||||
options: [{ label: 'A1', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
{
|
||||
question: 'Question 2?',
|
||||
header: 'Q2',
|
||||
options: [{ label: 'A2', description: '' }],
|
||||
multiSelect: false,
|
||||
},
|
||||
];
|
||||
|
||||
const onSubmit = vi.fn();
|
||||
const { stdin, lastFrame } = renderWithProviders(
|
||||
<AskUserDialog
|
||||
questions={multiQuestions}
|
||||
onSubmit={onSubmit}
|
||||
onCancel={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Answer Q1 and Q2 sequentialy
|
||||
act(() => {
|
||||
stdin.write('\r'); // Select A1 for Q1 -> triggers autoAdvance
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Question 2?');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.write('\r'); // Select A2 for Q2 -> triggers autoAdvance to Review
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(lastFrame()).toContain('Review your answers:');
|
||||
});
|
||||
|
||||
act(() => {
|
||||
stdin.write('\r'); // Submit from Review
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith({
|
||||
'0': 'A1',
|
||||
'1': 'A2',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
1105
packages/cli/src/ui/components/AskUserDialog.tsx
Normal file
1105
packages/cli/src/ui/components/AskUserDialog.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,138 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`AskUserDialog > Text type questions > renders text input for type: "text" 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ What should we name this component? │
|
||||
│ │
|
||||
│ > e.g., UserProfileCard │
|
||||
│ │
|
||||
│ │
|
||||
│ Enter to submit · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Text type questions > shows correct keyboard hints for text type 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Enter the variable name: │
|
||||
│ │
|
||||
│ > Enter your response │
|
||||
│ │
|
||||
│ │
|
||||
│ Enter to submit · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > Text type questions > shows default placeholder when none provided 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Enter the database connection string: │
|
||||
│ │
|
||||
│ > Enter your response │
|
||||
│ │
|
||||
│ │
|
||||
│ Enter to submit · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > allows navigating to Review tab and back 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ Tests │ □ Docs │ ≡ Review → │
|
||||
│ │
|
||||
│ Review your answers: │
|
||||
│ │
|
||||
│ ⚠ You have 2 unanswered questions │
|
||||
│ │
|
||||
│ Tests → (not answered) │
|
||||
│ Docs → (not answered) │
|
||||
│ │
|
||||
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > hides progress header for single question 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Which authentication method should we use? │
|
||||
│ │
|
||||
│ ● 1. OAuth 2.0 │
|
||||
│ Industry standard, supports SSO │
|
||||
│ 2. JWT tokens │
|
||||
│ Stateless, good for APIs │
|
||||
│ 3. Enter a custom value │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > renders question and options 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Which authentication method should we use? │
|
||||
│ │
|
||||
│ ● 1. OAuth 2.0 │
|
||||
│ Industry standard, supports SSO │
|
||||
│ 2. JWT tokens │
|
||||
│ Stateless, good for APIs │
|
||||
│ 3. Enter a custom value │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > shows Review tab in progress header for multiple questions 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ Framework │ □ Styling │ ≡ Review → │
|
||||
│ │
|
||||
│ Which framework? │
|
||||
│ │
|
||||
│ ● 1. React │
|
||||
│ Component library │
|
||||
│ 2. Vue │
|
||||
│ Progressive framework │
|
||||
│ 3. Enter a custom value │
|
||||
│ │
|
||||
│ Enter to select · ←/→ to switch questions · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > shows keyboard hints 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ Which authentication method should we use? │
|
||||
│ │
|
||||
│ ● 1. OAuth 2.0 │
|
||||
│ Industry standard, supports SSO │
|
||||
│ 2. JWT tokens │
|
||||
│ Stateless, good for APIs │
|
||||
│ 3. Enter a custom value │
|
||||
│ │
|
||||
│ Enter to select · ↑/↓ to navigate · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > shows progress header for multiple questions 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ Database │ □ ORM │ ≡ Review → │
|
||||
│ │
|
||||
│ Which database should we use? │
|
||||
│ │
|
||||
│ ● 1. PostgreSQL │
|
||||
│ Relational database │
|
||||
│ 2. MongoDB │
|
||||
│ Document database │
|
||||
│ 3. Enter a custom value │
|
||||
│ │
|
||||
│ Enter to select · ←/→ to switch questions · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
|
||||
exports[`AskUserDialog > shows warning for unanswered questions on Review tab 1`] = `
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
|
||||
│ ← □ License │ □ README │ ≡ Review → │
|
||||
│ │
|
||||
│ Review your answers: │
|
||||
│ │
|
||||
│ ⚠ You have 2 unanswered questions │
|
||||
│ │
|
||||
│ License → (not answered) │
|
||||
│ README → (not answered) │
|
||||
│ │
|
||||
│ Enter to submit · Tab/Shift+Tab to edit answers · Esc to cancel │
|
||||
╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯"
|
||||
`;
|
||||
@@ -31,6 +31,7 @@ export interface BaseSelectionListProps<
|
||||
showScrollArrows?: boolean;
|
||||
maxItemsToShow?: number;
|
||||
wrapAround?: boolean;
|
||||
focusKey?: string;
|
||||
renderItem: (item: TItem, context: RenderItemContext) => React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -61,6 +62,7 @@ export function BaseSelectionList<
|
||||
showScrollArrows = false,
|
||||
maxItemsToShow = 10,
|
||||
wrapAround = true,
|
||||
focusKey,
|
||||
renderItem,
|
||||
}: BaseSelectionListProps<T, TItem>): React.JSX.Element {
|
||||
const { activeIndex } = useSelectionList({
|
||||
@@ -71,6 +73,7 @@ export function BaseSelectionList<
|
||||
isFocused,
|
||||
showNumbers,
|
||||
wrapAround,
|
||||
focusKey,
|
||||
});
|
||||
|
||||
const [scrollOffset, setScrollOffset] = useState(0);
|
||||
@@ -143,7 +146,7 @@ export function BaseSelectionList<
|
||||
</Box>
|
||||
|
||||
{/* Item number */}
|
||||
{showNumbers && (
|
||||
{showNumbers && !item.hideNumber && (
|
||||
<Box
|
||||
marginRight={1}
|
||||
flexShrink={0}
|
||||
|
||||
157
packages/cli/src/ui/components/shared/TabHeader.test.tsx
Normal file
157
packages/cli/src/ui/components/shared/TabHeader.test.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
import { TabHeader, type Tab } from './TabHeader.js';
|
||||
|
||||
const MOCK_TABS: Tab[] = [
|
||||
{ key: '0', header: 'Tab 1' },
|
||||
{ key: '1', header: 'Tab 2' },
|
||||
{ key: '2', header: 'Tab 3' },
|
||||
];
|
||||
|
||||
describe('TabHeader', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders null for single tab', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TabHeader
|
||||
tabs={[{ key: '0', header: 'Only Tab' }]}
|
||||
currentIndex={0}
|
||||
/>,
|
||||
);
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('renders all tab headers', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Tab 1');
|
||||
expect(frame).toContain('Tab 2');
|
||||
expect(frame).toContain('Tab 3');
|
||||
});
|
||||
|
||||
it('renders separators between tabs', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
// Should have 2 separators for 3 tabs
|
||||
const separatorCount = (frame?.match(/│/g) || []).length;
|
||||
expect(separatorCount).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('arrows', () => {
|
||||
it('shows arrows by default', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('←');
|
||||
expect(frame).toContain('→');
|
||||
});
|
||||
|
||||
it('hides arrows when showArrows is false', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TabHeader tabs={MOCK_TABS} currentIndex={0} showArrows={false} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).not.toContain('←');
|
||||
expect(frame).not.toContain('→');
|
||||
});
|
||||
});
|
||||
|
||||
describe('status icons', () => {
|
||||
it('shows status icons by default', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TabHeader tabs={MOCK_TABS} currentIndex={0} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
// Default uncompleted icon is □
|
||||
expect(frame).toContain('□');
|
||||
});
|
||||
|
||||
it('hides status icons when showStatusIcons is false', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TabHeader tabs={MOCK_TABS} currentIndex={0} showStatusIcons={false} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).not.toContain('□');
|
||||
expect(frame).not.toContain('✓');
|
||||
});
|
||||
|
||||
it('shows checkmark for completed tabs', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TabHeader
|
||||
tabs={MOCK_TABS}
|
||||
currentIndex={0}
|
||||
completedIndices={new Set([0, 2])}
|
||||
/>,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
// Should have 2 checkmarks and 1 box
|
||||
const checkmarkCount = (frame?.match(/✓/g) || []).length;
|
||||
const boxCount = (frame?.match(/□/g) || []).length;
|
||||
expect(checkmarkCount).toBe(2);
|
||||
expect(boxCount).toBe(1);
|
||||
});
|
||||
|
||||
it('shows special icon for special tabs', () => {
|
||||
const tabsWithSpecial: Tab[] = [
|
||||
{ key: '0', header: 'Tab 1' },
|
||||
{ key: '1', header: 'Review', isSpecial: true },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TabHeader tabs={tabsWithSpecial} currentIndex={0} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
// Special tab shows ≡ icon
|
||||
expect(frame).toContain('≡');
|
||||
});
|
||||
|
||||
it('uses tab statusIcon when provided', () => {
|
||||
const tabsWithCustomIcon: Tab[] = [
|
||||
{ key: '0', header: 'Tab 1', statusIcon: '★' },
|
||||
{ key: '1', header: 'Tab 2' },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TabHeader tabs={tabsWithCustomIcon} currentIndex={0} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('★');
|
||||
});
|
||||
|
||||
it('uses custom renderStatusIcon when provided', () => {
|
||||
const renderStatusIcon = () => '•';
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TabHeader
|
||||
tabs={MOCK_TABS}
|
||||
currentIndex={0}
|
||||
renderStatusIcon={renderStatusIcon}
|
||||
/>,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
const bulletCount = (frame?.match(/•/g) || []).length;
|
||||
expect(bulletCount).toBe(3);
|
||||
});
|
||||
|
||||
it('falls back to default when renderStatusIcon returns undefined', () => {
|
||||
const renderStatusIcon = () => undefined;
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TabHeader
|
||||
tabs={MOCK_TABS}
|
||||
currentIndex={0}
|
||||
renderStatusIcon={renderStatusIcon}
|
||||
/>,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('□');
|
||||
});
|
||||
});
|
||||
});
|
||||
110
packages/cli/src/ui/components/shared/TabHeader.tsx
Normal file
110
packages/cli/src/ui/components/shared/TabHeader.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Text, Box } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
|
||||
/**
|
||||
* Represents a single tab in the TabHeader.
|
||||
*/
|
||||
export interface Tab {
|
||||
/** Unique identifier for this tab */
|
||||
key: string;
|
||||
/** Header text displayed in the tab indicator */
|
||||
header: string;
|
||||
/** Optional custom status icon for this tab */
|
||||
statusIcon?: string;
|
||||
/** Whether this is a special tab (like "Review") - uses different default icon */
|
||||
isSpecial?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Props for the TabHeader component.
|
||||
*/
|
||||
export interface TabHeaderProps {
|
||||
/** Array of tab definitions */
|
||||
tabs: Tab[];
|
||||
/** Currently active tab index */
|
||||
currentIndex: number;
|
||||
/** Set of indices for tabs that show a completion indicator */
|
||||
completedIndices?: Set<number>;
|
||||
/** Show navigation arrow hints on sides (default: true) */
|
||||
showArrows?: boolean;
|
||||
/** Show status icons (checkmark/box) before tab headers (default: true) */
|
||||
showStatusIcons?: boolean;
|
||||
/**
|
||||
* Custom status icon renderer. Return undefined to use default icons.
|
||||
* Default icons: '✓' for completed, '□' for incomplete, '≡' for special tabs
|
||||
*/
|
||||
renderStatusIcon?: (
|
||||
tab: Tab,
|
||||
index: number,
|
||||
isCompleted: boolean,
|
||||
) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A header component that displays tab indicators for multi-tab interfaces.
|
||||
*
|
||||
* Renders in the format: `← Tab1 │ Tab2 │ Tab3 →`
|
||||
*
|
||||
* Features:
|
||||
* - Shows completion status (✓ or □) per tab
|
||||
* - Highlights current tab with accent color
|
||||
* - Supports special tabs (like "Review") with different icons
|
||||
* - Customizable status icons
|
||||
*/
|
||||
export function TabHeader({
|
||||
tabs,
|
||||
currentIndex,
|
||||
completedIndices = new Set(),
|
||||
showArrows = true,
|
||||
showStatusIcons = true,
|
||||
renderStatusIcon,
|
||||
}: TabHeaderProps): React.JSX.Element | null {
|
||||
if (tabs.length <= 1) return null;
|
||||
|
||||
const getStatusIcon = (tab: Tab, index: number): string => {
|
||||
const isCompleted = completedIndices.has(index);
|
||||
|
||||
// Try custom renderer first
|
||||
if (renderStatusIcon) {
|
||||
const customIcon = renderStatusIcon(tab, index, isCompleted);
|
||||
if (customIcon !== undefined) return customIcon;
|
||||
}
|
||||
|
||||
// Use tab's own icon if provided
|
||||
if (tab.statusIcon) return tab.statusIcon;
|
||||
|
||||
// Default icons
|
||||
if (tab.isSpecial) return '\u2261'; // ≡
|
||||
return isCompleted ? '\u2713' : '\u25A1'; // ✓ or □
|
||||
};
|
||||
|
||||
return (
|
||||
<Box flexDirection="row" marginBottom={1}>
|
||||
{showArrows && <Text color={theme.text.secondary}>{'\u2190 '}</Text>}
|
||||
{tabs.map((tab, i) => (
|
||||
<React.Fragment key={tab.key}>
|
||||
{i > 0 && <Text color={theme.text.secondary}>{' \u2502 '}</Text>}
|
||||
{showStatusIcons && (
|
||||
<Text color={theme.text.secondary}>{getStatusIcon(tab, i)} </Text>
|
||||
)}
|
||||
<Text
|
||||
color={
|
||||
i === currentIndex ? theme.text.accent : theme.text.secondary
|
||||
}
|
||||
bold={i === currentIndex}
|
||||
>
|
||||
{tab.header}
|
||||
</Text>
|
||||
</React.Fragment>
|
||||
))}
|
||||
{showArrows && <Text color={theme.text.secondary}>{' \u2192'}</Text>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export interface SelectionListItem<T> {
|
||||
key: string;
|
||||
value: T;
|
||||
disabled?: boolean;
|
||||
hideNumber?: boolean;
|
||||
}
|
||||
|
||||
interface BaseSelectionItem {
|
||||
@@ -28,6 +29,7 @@ export interface UseSelectionListOptions<T> {
|
||||
isFocused?: boolean;
|
||||
showNumbers?: boolean;
|
||||
wrapAround?: boolean;
|
||||
focusKey?: string;
|
||||
}
|
||||
|
||||
export interface UseSelectionListResult {
|
||||
@@ -285,6 +287,7 @@ export function useSelectionList<T>({
|
||||
isFocused = true,
|
||||
showNumbers = false,
|
||||
wrapAround = true,
|
||||
focusKey,
|
||||
}: UseSelectionListOptions<T>): UseSelectionListResult {
|
||||
const baseItems = toBaseItems(items);
|
||||
|
||||
@@ -302,6 +305,25 @@ export function useSelectionList<T>({
|
||||
const prevBaseItemsRef = useRef(baseItems);
|
||||
const prevInitialIndexRef = useRef(initialIndex);
|
||||
const prevWrapAroundRef = useRef(wrapAround);
|
||||
const lastProcessedFocusKeyRef = useRef<string | undefined>(undefined);
|
||||
|
||||
// Handle programmatic focus changes via focusKey
|
||||
useEffect(() => {
|
||||
if (focusKey === undefined) {
|
||||
lastProcessedFocusKeyRef.current = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusKey === lastProcessedFocusKeyRef.current) return;
|
||||
|
||||
const index = items.findIndex(
|
||||
(item) => item.key === focusKey && !item.disabled,
|
||||
);
|
||||
if (index !== -1) {
|
||||
lastProcessedFocusKeyRef.current = focusKey;
|
||||
dispatch({ type: 'SET_ACTIVE_INDEX', payload: { index } });
|
||||
}
|
||||
}, [focusKey, items]);
|
||||
|
||||
// Initialize/synchronize state when initialIndex or items change
|
||||
useEffect(() => {
|
||||
|
||||
276
packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
Normal file
276
packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { act } from 'react';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useTabbedNavigation } from './useTabbedNavigation.js';
|
||||
|
||||
vi.mock('./useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../keyMatchers.js', () => ({
|
||||
keyMatchers: {
|
||||
'cursor.left': vi.fn((key) => key.name === 'left'),
|
||||
'cursor.right': vi.fn((key) => key.name === 'right'),
|
||||
},
|
||||
Command: {
|
||||
MOVE_LEFT: 'cursor.left',
|
||||
MOVE_RIGHT: 'cursor.right',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useTabbedNavigation', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initialization', () => {
|
||||
it('returns initial index of 0 by default', () => {
|
||||
const { result } = renderHook(() => useTabbedNavigation({ tabCount: 3 }));
|
||||
expect(result.current.currentIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('returns specified initial index', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, initialIndex: 2 }),
|
||||
);
|
||||
expect(result.current.currentIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('clamps initial index to valid range', () => {
|
||||
const { result: high } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, initialIndex: 10 }),
|
||||
);
|
||||
expect(high.current.currentIndex).toBe(2);
|
||||
|
||||
const { result: negative } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, initialIndex: -1 }),
|
||||
);
|
||||
expect(negative.current.currentIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('goToNextTab', () => {
|
||||
it('advances to next tab', () => {
|
||||
const { result } = renderHook(() => useTabbedNavigation({ tabCount: 3 }));
|
||||
|
||||
act(() => {
|
||||
result.current.goToNextTab();
|
||||
});
|
||||
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('stops at last tab when wrapAround is false', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabbedNavigation({
|
||||
tabCount: 3,
|
||||
initialIndex: 2,
|
||||
wrapAround: false,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.goToNextTab();
|
||||
});
|
||||
|
||||
expect(result.current.currentIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('wraps to first tab when wrapAround is true', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, initialIndex: 2, wrapAround: true }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.goToNextTab();
|
||||
});
|
||||
|
||||
expect(result.current.currentIndex).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('goToPrevTab', () => {
|
||||
it('moves to previous tab', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, initialIndex: 2 }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.goToPrevTab();
|
||||
});
|
||||
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
});
|
||||
|
||||
it('stops at first tab when wrapAround is false', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabbedNavigation({
|
||||
tabCount: 3,
|
||||
initialIndex: 0,
|
||||
wrapAround: false,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.goToPrevTab();
|
||||
});
|
||||
|
||||
expect(result.current.currentIndex).toBe(0);
|
||||
});
|
||||
|
||||
it('wraps to last tab when wrapAround is true', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, initialIndex: 0, wrapAround: true }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.goToPrevTab();
|
||||
});
|
||||
|
||||
expect(result.current.currentIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCurrentIndex', () => {
|
||||
it('sets index directly', () => {
|
||||
const { result } = renderHook(() => useTabbedNavigation({ tabCount: 3 }));
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentIndex(2);
|
||||
});
|
||||
|
||||
expect(result.current.currentIndex).toBe(2);
|
||||
});
|
||||
|
||||
it('ignores out-of-bounds index', () => {
|
||||
const { result } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, initialIndex: 1 }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentIndex(10);
|
||||
});
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentIndex(-1);
|
||||
});
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isNavigationBlocked', () => {
|
||||
it('blocks navigation when callback returns true', () => {
|
||||
const isNavigationBlocked = vi.fn(() => true);
|
||||
const { result } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, isNavigationBlocked }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.goToNextTab();
|
||||
});
|
||||
|
||||
expect(result.current.currentIndex).toBe(0);
|
||||
expect(isNavigationBlocked).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows navigation when callback returns false', () => {
|
||||
const isNavigationBlocked = vi.fn(() => false);
|
||||
const { result } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, isNavigationBlocked }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.goToNextTab();
|
||||
});
|
||||
|
||||
expect(result.current.currentIndex).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('onTabChange callback', () => {
|
||||
it('calls onTabChange when tab changes via goToNextTab', () => {
|
||||
const onTabChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, onTabChange }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.goToNextTab();
|
||||
});
|
||||
|
||||
expect(onTabChange).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('calls onTabChange when tab changes via setCurrentIndex', () => {
|
||||
const onTabChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, onTabChange }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentIndex(2);
|
||||
});
|
||||
|
||||
expect(onTabChange).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('does not call onTabChange when tab does not change', () => {
|
||||
const onTabChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, onTabChange }),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentIndex(0);
|
||||
});
|
||||
|
||||
expect(onTabChange).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isFirstTab and isLastTab', () => {
|
||||
it('returns correct boundary flags based on position', () => {
|
||||
const { result: first } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, initialIndex: 0 }),
|
||||
);
|
||||
expect(first.current.isFirstTab).toBe(true);
|
||||
expect(first.current.isLastTab).toBe(false);
|
||||
|
||||
const { result: last } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, initialIndex: 2 }),
|
||||
);
|
||||
expect(last.current.isFirstTab).toBe(false);
|
||||
expect(last.current.isLastTab).toBe(true);
|
||||
|
||||
const { result: middle } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount: 3, initialIndex: 1 }),
|
||||
);
|
||||
expect(middle.current.isFirstTab).toBe(false);
|
||||
expect(middle.current.isLastTab).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabCount changes', () => {
|
||||
it('reinitializes when tabCount changes', () => {
|
||||
let tabCount = 5;
|
||||
const { result, rerender } = renderHook(() =>
|
||||
useTabbedNavigation({ tabCount, initialIndex: 4 }),
|
||||
);
|
||||
|
||||
expect(result.current.currentIndex).toBe(4);
|
||||
|
||||
tabCount = 3;
|
||||
rerender();
|
||||
|
||||
// Should clamp to valid range
|
||||
expect(result.current.currentIndex).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
240
packages/cli/src/ui/hooks/useTabbedNavigation.ts
Normal file
240
packages/cli/src/ui/hooks/useTabbedNavigation.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @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 && key.name === 'tab' && !key.shift) {
|
||||
goToNextTab();
|
||||
}
|
||||
},
|
||||
[
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user