Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/group-back-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@clack/core': minor
'@clack/prompts': minor
---

Add back navigation to `group`

The `group` utility can now return to a previous step. Pressing `Shift+Tab`
during a prompt returns to the previous one. Answers are kept, and each step
receives its previous answer as `previous`, so passing it to the prompt's
`initialValue` restores what the user had entered. It is on by default and can
be turned off with `group(prompts, { navigate: false })`; an
`onBack({ from, to, results })` callback is also available.

Under the hood this adds a `back` action to `@clack/core` (unbound by default, so
standalone prompts are unchanged), a `BACK_SYMBOL`/`isBack()` pair mirroring
`CANCEL_SYMBOL`/`isCancel()`, and a `'back'` prompt state. Closes #39.
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,12 @@ export type { TextOptions } from './prompts/text.js';
export { default as TextPrompt } from './prompts/text.js';
export type { ClackState as State } from './types.js';
export {
BACK_SYMBOL,
block,
CANCEL_SYMBOL,
getColumns,
getRows,
isBack,
isCancel,
wrapTextWithPrefix,
} from './utils/index.js';
Expand Down
33 changes: 28 additions & 5 deletions packages/core/src/prompts/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { cursor, erase } from 'sisteransi';
import type { ClackEvents, ClackState } from '../types.js';
import type { Action } from '../utils/index.js';
import {
BACK_SYMBOL,
CANCEL_SYMBOL,
diffLines,
getRows,
Expand Down Expand Up @@ -144,7 +145,8 @@ export default class Prompt<TValue> {
}

public prompt() {
return new Promise<TValue | typeof CANCEL_SYMBOL | undefined>((resolve) => {
type Resolution = TValue | typeof CANCEL_SYMBOL | typeof BACK_SYMBOL | undefined;
return new Promise<Resolution>((resolve) => {
if (this._abortSignal) {
if (this._abortSignal.aborted) {
this.state = 'cancel';
Expand Down Expand Up @@ -194,6 +196,12 @@ export default class Prompt<TValue> {
setRawMode(this.input, false);
resolve(CANCEL_SYMBOL);
});
this.once('back', () => {
this.output.write(cursor.show);
this.output.off('resize', this.render);
setRawMode(this.input, false);
resolve(BACK_SYMBOL);
});
});
}

Expand Down Expand Up @@ -280,20 +288,35 @@ export default class Prompt<TValue> {
if (isActionKey([char, key?.name, key?.sequence], 'cancel')) {
this.state = 'cancel';
}
// `back` lets a caller (e.g. the `group` utility) return to a previous
// step. It is unbound by default, so standalone prompts are unaffected.
if (this.state !== 'cancel' && isActionKey([char, key?.name, key?.sequence], 'back')) {
this.state = 'back';
}

if (this.state === 'submit' || this.state === 'cancel') {
if (this.state === 'submit' || this.state === 'cancel' || this.state === 'back') {
this.emit('finalize');
}
this.render();
if (this.state === 'submit' || this.state === 'cancel') {
// `back` leaves no frame behind (see close()), so skip its render.
if (this.state !== 'back') {
this.render();
}
if (this.state === 'submit' || this.state === 'cancel' || this.state === 'back') {
this.close();
}
}

protected close() {
this.input.unpipe();
this.input.removeListener('keypress', this.onKeypress);
this.output.write('\n');
if (this.state === 'back') {
// Going back: erase this step's output so navigation stays clean,
// rather than leaving a finalized frame behind like submit/cancel do.
this.restoreCursor();
this.output.write(erase.down());
} else {
this.output.write('\n');
}
setRawMode(this.input, false);
this.rl?.close();
this.rl = undefined;
Expand Down
10 changes: 9 additions & 1 deletion packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,14 @@ import type { Action } from './utils/settings.js';
/**
* The state of the prompt
*/
export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error' | 'validating';
export type ClackState =
| 'initial'
| 'active'
| 'cancel'
| 'back'
| 'submit'
| 'error'
| 'validating';

/**
* Typed event emitter for clack
Expand All @@ -13,6 +20,7 @@ export interface ClackEvents<TValue> {
initial: (value?: any) => void;
active: (value?: any) => void;
cancel: (value?: any) => void;
back: (value?: any) => void;
submit: (value?: any) => void;
error: (value?: any) => void;
validating: (value?: any) => void;
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ export function isCancel(value: unknown): value is typeof CANCEL_SYMBOL {
return value === CANCEL_SYMBOL;
}

export const BACK_SYMBOL = Symbol('clack:back');

export function isBack(value: unknown): value is typeof BACK_SYMBOL {
return value === BACK_SYMBOL;
}

export function setRawMode(input: Readable, value: boolean) {
const i = input as typeof stdin;

Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/utils/settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const;
const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel', 'back'] as const;
export type Action = (typeof actions)[number];

const DEFAULT_MONTH_NAMES = [
Expand Down
4 changes: 4 additions & 0 deletions packages/prompts/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export const symbol = (state: State) => {
return styleText('green', S_STEP_SUBMIT);
case 'validating':
return styleText('dim', S_STEP_ACTIVE);
case 'back':
return styleText('dim', S_STEP_ACTIVE);
}
};

Expand All @@ -66,6 +68,8 @@ export const symbolBar = (state: State) => {
return styleText('yellow', S_BAR);
case 'submit':
return styleText('green', S_BAR);
case 'back':
return styleText('dim', S_BAR);
}
};

Expand Down
104 changes: 87 additions & 17 deletions packages/prompts/src/group.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { isCancel } from '@clack/core';
import { isBack, isCancel, settings } from '@clack/core';

/** Shift+Tab. Bound to the `back` action while a navigable group is running. */
const SHIFT_TAB_SEQUENCE = '\x1b[Z';

type Prettify<T> = {
[P in keyof T]: T[P];
Expand All @@ -20,6 +23,26 @@ export interface PromptGroupOptions<T> {
* Called when any one of the prompts is canceled.
*/
onCancel?: (opts: { results: Prettify<Partial<PromptGroupAwaitedReturn<T>>> }) => void;

/**
* Enable back navigation. When `true` (the default), pressing `Shift+Tab`
* during a prompt returns to the previous step. Answers are kept, and each
* step receives its previous answer as `previous` so it can be restored. Set
* to `false` to keep the group strictly forward-only.
*
* @default true
*/
navigate?: boolean;

/**
* Called when the user returns to a previous step, with the name of the step
* being left and the step being returned to.
*/
onBack?: (opts: {
from: keyof T;
to: keyof T;
results: Prettify<Partial<PromptGroupAwaitedReturn<T>>>;
}) => void;
}

/**
Expand All @@ -29,6 +52,12 @@ export interface PromptGroupOptions<T> {
export type PromptGroup<T> = {
[P in keyof T]: (opts: {
results: Prettify<Partial<PromptGroupAwaitedReturn<Omit<T, P>>>>;
/**
* This step's previously-submitted answer, if the user has visited it
* before (e.g. after going back). Pass it to the prompt's `initialValue`
* to restore what they had entered.
*/
previous?: PromptGroupAwaitedReturn<T>[P];
}) => undefined | Promise<T[P] | undefined>;
};

Expand All @@ -44,11 +73,14 @@ export type PromptGroup<T> = {
* import { group, text, password } from '@clack/prompts';
*
* const account = await group({
* email: () => text({
* // `Shift+Tab` goes back; pass `previous` to `initialValue` to restore it.
* email: ({ previous }) => text({
* message: 'What is your email address?',
* initialValue: previous,
* }),
* username: ({ results }) => text({
* username: ({ results, previous }) => text({
* message: 'What is your username?',
* initialValue: previous,
* placeholder: results.email?.replace(/@.+$/, '').toLowerCase() ?? '',
* }),
* password: () => password({
Expand All @@ -63,23 +95,61 @@ export const group = async <T>(
): Promise<Prettify<PromptGroupAwaitedReturn<T>>> => {
const results = {} as any;
const promptNames = Object.keys(prompts);
const navigate = opts?.navigate !== false;

for (const name of promptNames) {
const prompt = prompts[name as keyof T];
const result = await prompt({ results })?.catch((e) => {
throw e;
});
// We bind Shift+Tab -> `back` only while a step that *can* go back is showing,
// and restore the previous alias state afterwards so we never clobber a user's
// own binding. We only manage the alias when the user hasn't bound it already.
const manageAlias = navigate && !settings.aliases.has(SHIFT_TAB_SEQUENCE);
const setBackEnabled = (enabled: boolean) => {
if (!manageAlias) return;
if (enabled) settings.aliases.set(SHIFT_TAB_SEQUENCE, 'back');
else settings.aliases.delete(SHIFT_TAB_SEQUENCE);
};

// Pass the results to the onCancel function
// so the user can decide what to do with the results
// TODO: Switch to callback within core to avoid isCancel Fn
if (typeof opts?.onCancel === 'function' && isCancel(result)) {
results[name] = 'canceled';
opts.onCancel({ results });
continue;
}
try {
let index = 0;
while (index < promptNames.length) {
// There is nowhere to go back to from the first step, so `back` is
// disabled there (Shift+Tab does nothing); enabled from the second on.
setBackEnabled(index > 0);

// Safe: the loop condition guarantees `index` is in range.
const name = promptNames[index] as string;
const prompt = prompts[name as keyof T];
// `previous` lets a revisited step restore what the user had entered.
const result = await prompt({ results, previous: results[name] })?.catch((e) => {
throw e;
});

results[name] = result;
// `back`: return to the previous step. Answers are kept (not discarded),
// so each step can offer them back via `previous`. Guarded against the
// first step in case a caller forces `back` there programmatically.
if (navigate && isBack(result)) {
if (index > 0) {
const from = name as keyof T;
index--;
const to = promptNames[index] as keyof T;
opts?.onBack?.({ from, to, results });
}
continue;
}

// Pass the results to the onCancel function
// so the user can decide what to do with the results
// TODO: Switch to callback within core to avoid isCancel Fn
if (typeof opts?.onCancel === 'function' && isCancel(result)) {
results[name] = 'canceled';
opts.onCancel({ results });
index++;
continue;
}

results[name] = result;
index++;
}
} finally {
setBackEnabled(false);
}

return results;
Expand Down
9 changes: 8 additions & 1 deletion packages/prompts/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
export type { ClackSettings } from '@clack/core';
export { CANCEL_SYMBOL, isCancel, settings, updateSettings } from '@clack/core';
export {
BACK_SYMBOL,
CANCEL_SYMBOL,
isBack,
isCancel,
settings,
updateSettings,
} from '@clack/core';

export * from './autocomplete.js';
export * from './box.js';
Expand Down
Loading
Loading