Skip to content

Commit 30c9666

Browse files
feat(prompts): add back navigation to group (#39)
Pressing Shift+Tab during a grouped prompt returns to the previous step, discarding its answer and re-running the previous prompt. - @clack/core: add a `back` action (unbound by default, so standalone prompts are unchanged), a `BACK_SYMBOL`/`isBack()` pair mirroring `CANCEL_SYMBOL`/`isCancel()`, and a `'back'` prompt state. - @clack/prompts: rework `group()` into a navigable loop. Back is on by default (disable with `navigate: false`) and exposes `onBack`. Closes #39
1 parent f325a53 commit 30c9666

9 files changed

Lines changed: 293 additions & 21 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
'@clack/core': minor
3+
'@clack/prompts': minor
4+
---
5+
6+
Add back navigation to `group`
7+
8+
The `group` utility can now return to a previous step. Pressing `Shift+Tab`
9+
during a prompt discards the current answer and re-runs the previous prompt.
10+
It is on by default and can be turned off with `group(prompts, { navigate: false })`;
11+
an `onBack({ from, to, results })` callback is also available.
12+
13+
Under the hood this adds a `back` action to `@clack/core` (unbound by default, so
14+
standalone prompts are unchanged), a `BACK_SYMBOL`/`isBack()` pair mirroring
15+
`CANCEL_SYMBOL`/`isCancel()`, and a `'back'` prompt state. Closes #39.

‎packages/core/src/index.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,12 @@ export type { TextOptions } from './prompts/text.js';
2222
export { default as TextPrompt } from './prompts/text.js';
2323
export type { ClackState as State } from './types.js';
2424
export {
25+
BACK_SYMBOL,
2526
block,
2627
CANCEL_SYMBOL,
2728
getColumns,
2829
getRows,
30+
isBack,
2931
isCancel,
3032
wrapTextWithPrefix,
3133
} from './utils/index.js';

‎packages/core/src/prompts/prompt.ts‎

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { cursor, erase } from 'sisteransi';
66
import type { ClackEvents, ClackState } from '../types.js';
77
import type { Action } from '../utils/index.js';
88
import {
9+
BACK_SYMBOL,
910
CANCEL_SYMBOL,
1011
diffLines,
1112
getRows,
@@ -144,7 +145,8 @@ export default class Prompt<TValue> {
144145
}
145146

146147
public prompt() {
147-
return new Promise<TValue | typeof CANCEL_SYMBOL | undefined>((resolve) => {
148+
type Resolution = TValue | typeof CANCEL_SYMBOL | typeof BACK_SYMBOL | undefined;
149+
return new Promise<Resolution>((resolve) => {
148150
if (this._abortSignal) {
149151
if (this._abortSignal.aborted) {
150152
this.state = 'cancel';
@@ -194,6 +196,12 @@ export default class Prompt<TValue> {
194196
setRawMode(this.input, false);
195197
resolve(CANCEL_SYMBOL);
196198
});
199+
this.once('back', () => {
200+
this.output.write(cursor.show);
201+
this.output.off('resize', this.render);
202+
setRawMode(this.input, false);
203+
resolve(BACK_SYMBOL);
204+
});
197205
});
198206
}
199207

@@ -280,12 +288,17 @@ export default class Prompt<TValue> {
280288
if (isActionKey([char, key?.name, key?.sequence], 'cancel')) {
281289
this.state = 'cancel';
282290
}
291+
// `back` lets a caller (e.g. the `group` utility) return to a previous
292+
// step. It is unbound by default, so standalone prompts are unaffected.
293+
if (this.state !== 'cancel' && isActionKey([char, key?.name, key?.sequence], 'back')) {
294+
this.state = 'back';
295+
}
283296

284-
if (this.state === 'submit' || this.state === 'cancel') {
297+
if (this.state === 'submit' || this.state === 'cancel' || this.state === 'back') {
285298
this.emit('finalize');
286299
}
287300
this.render();
288-
if (this.state === 'submit' || this.state === 'cancel') {
301+
if (this.state === 'submit' || this.state === 'cancel' || this.state === 'back') {
289302
this.close();
290303
}
291304
}

‎packages/core/src/types.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,14 @@ import type { Action } from './utils/settings.js';
44
/**
55
* The state of the prompt
66
*/
7-
export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error' | 'validating';
7+
export type ClackState =
8+
| 'initial'
9+
| 'active'
10+
| 'cancel'
11+
| 'back'
12+
| 'submit'
13+
| 'error'
14+
| 'validating';
815

916
/**
1017
* Typed event emitter for clack
@@ -13,6 +20,7 @@ export interface ClackEvents<TValue> {
1320
initial: (value?: any) => void;
1421
active: (value?: any) => void;
1522
cancel: (value?: any) => void;
23+
back: (value?: any) => void;
1624
submit: (value?: any) => void;
1725
error: (value?: any) => void;
1826
validating: (value?: any) => void;

‎packages/core/src/utils/index.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,12 @@ export function isCancel(value: unknown): value is typeof CANCEL_SYMBOL {
1818
return value === CANCEL_SYMBOL;
1919
}
2020

21+
export const BACK_SYMBOL = Symbol('clack:back');
22+
23+
export function isBack(value: unknown): value is typeof BACK_SYMBOL {
24+
return value === BACK_SYMBOL;
25+
}
26+
2127
export function setRawMode(input: Readable, value: boolean) {
2228
const i = input as typeof stdin;
2329

‎packages/core/src/utils/settings.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const;
1+
const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel', 'back'] as const;
22
export type Action = (typeof actions)[number];
33

44
const DEFAULT_MONTH_NAMES = [

‎packages/prompts/src/group.ts‎

Lines changed: 69 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
import { isCancel } from '@clack/core';
1+
import { isBack, isCancel, settings } from '@clack/core';
2+
3+
/** Shift+Tab. Bound to the `back` action while a navigable group is running. */
4+
const SHIFT_TAB_SEQUENCE = '\x1b[Z';
25

36
type Prettify<T> = {
47
[P in keyof T]: T[P];
@@ -20,6 +23,25 @@ export interface PromptGroupOptions<T> {
2023
* Called when any one of the prompts is canceled.
2124
*/
2225
onCancel?: (opts: { results: Prettify<Partial<PromptGroupAwaitedReturn<T>>> }) => void;
26+
27+
/**
28+
* Enable back navigation. When `true` (the default), pressing `Shift+Tab`
29+
* during a prompt returns to the previous step, discarding its answer. Set
30+
* to `false` to keep the group strictly forward-only.
31+
*
32+
* @default true
33+
*/
34+
navigate?: boolean;
35+
36+
/**
37+
* Called when the user returns to a previous step, with the name of the step
38+
* being left and the step being returned to.
39+
*/
40+
onBack?: (opts: {
41+
from: keyof T;
42+
to: keyof T;
43+
results: Prettify<Partial<PromptGroupAwaitedReturn<T>>>;
44+
}) => void;
2345
}
2446

2547
/**
@@ -63,23 +85,55 @@ export const group = async <T>(
6385
): Promise<Prettify<PromptGroupAwaitedReturn<T>>> => {
6486
const results = {} as any;
6587
const promptNames = Object.keys(prompts);
88+
const navigate = opts?.navigate !== false;
6689

67-
for (const name of promptNames) {
68-
const prompt = prompts[name as keyof T];
69-
const result = await prompt({ results })?.catch((e) => {
70-
throw e;
71-
});
90+
// Bind Shift+Tab -> `back` for the duration of a navigable group, and restore
91+
// the previous alias state afterwards so we never clobber a user's binding.
92+
const hadBackAlias = settings.aliases.has(SHIFT_TAB_SEQUENCE);
93+
if (navigate && !hadBackAlias) {
94+
settings.aliases.set(SHIFT_TAB_SEQUENCE, 'back');
95+
}
7296

73-
// Pass the results to the onCancel function
74-
// so the user can decide what to do with the results
75-
// TODO: Switch to callback within core to avoid isCancel Fn
76-
if (typeof opts?.onCancel === 'function' && isCancel(result)) {
77-
results[name] = 'canceled';
78-
opts.onCancel({ results });
79-
continue;
80-
}
97+
try {
98+
let index = 0;
99+
while (index < promptNames.length) {
100+
// Safe: the loop condition guarantees `index` is in range.
101+
const name = promptNames[index] as string;
102+
const prompt = prompts[name as keyof T];
103+
const result = await prompt({ results })?.catch((e) => {
104+
throw e;
105+
});
106+
107+
// `back`: discard this answer and return to the previous step. On the
108+
// first step there is nowhere to go, so we simply re-run it.
109+
if (navigate && isBack(result)) {
110+
if (index > 0) {
111+
const from = name as keyof T;
112+
index--;
113+
const to = promptNames[index] as keyof T;
114+
delete results[to as keyof typeof results];
115+
opts?.onBack?.({ from, to, results });
116+
}
117+
continue;
118+
}
81119

82-
results[name] = result;
120+
// Pass the results to the onCancel function
121+
// so the user can decide what to do with the results
122+
// TODO: Switch to callback within core to avoid isCancel Fn
123+
if (typeof opts?.onCancel === 'function' && isCancel(result)) {
124+
results[name] = 'canceled';
125+
opts.onCancel({ results });
126+
index++;
127+
continue;
128+
}
129+
130+
results[name] = result;
131+
index++;
132+
}
133+
} finally {
134+
if (navigate && !hadBackAlias) {
135+
settings.aliases.delete(SHIFT_TAB_SEQUENCE);
136+
}
83137
}
84138

85139
return results;

‎packages/prompts/src/index.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
export type { ClackSettings } from '@clack/core';
2-
export { CANCEL_SYMBOL, isCancel, settings, updateSettings } from '@clack/core';
2+
export {
3+
BACK_SYMBOL,
4+
CANCEL_SYMBOL,
5+
isBack,
6+
isCancel,
7+
settings,
8+
updateSettings,
9+
} from '@clack/core';
310

411
export * from './autocomplete.js';
512
export * from './box.js';

0 commit comments

Comments
 (0)