Skip to content

Commit 2770eda

Browse files
authored
improve types event emitter & global aliases (#147)
2 parents 2bbd33e + 4c535a1 commit 2770eda

File tree

11 files changed

+209
-89
lines changed

11 files changed

+209
-89
lines changed

.changeset/swift-jars-destroy.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@clack/core': patch
3+
---
4+
5+
Improves types for events and interaction states.

examples/basic/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,14 @@ async function main() {
77

88
await setTimeout(1000);
99

10+
p.setGlobalAliases([
11+
['w', 'up'],
12+
['s', 'down'],
13+
['a', 'left'],
14+
['d', 'right'],
15+
['escape', 'cancel'],
16+
]);
17+
1018
p.intro(`${color.bgCyan(color.black(' create-app '))}`);
1119

1220
const project = await p.group(

packages/core/src/index.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ export { default as ConfirmPrompt } from './prompts/confirm';
22
export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect';
33
export { default as MultiSelectPrompt } from './prompts/multi-select';
44
export { default as PasswordPrompt } from './prompts/password';
5-
export { default as Prompt, isCancel } from './prompts/prompt';
6-
export type { State } from './prompts/prompt';
5+
export { default as Prompt } from './prompts/prompt';
76
export { default as SelectPrompt } from './prompts/select';
87
export { default as SelectKeyPrompt } from './prompts/select-key';
98
export { default as TextPrompt } from './prompts/text';
10-
export { block } from './utils';
9+
export type { ClackState as State } from './types';
10+
export { block, isCancel, setGlobalAliases } from './utils';
11+

packages/core/src/prompts/prompt.ts

Lines changed: 80 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,13 @@
1-
import type { Key, ReadLine } from 'node:readline';
2-
31
import { stdin, stdout } from 'node:process';
4-
import readline from 'node:readline';
2+
import readline, { type Key, type ReadLine } from 'node:readline';
53
import { Readable, Writable } from 'node:stream';
64
import { WriteStream } from 'node:tty';
75
import { cursor, erase } from 'sisteransi';
86
import wrap from 'wrap-ansi';
97

10-
function diffLines(a: string, b: string) {
11-
if (a === b) return;
12-
13-
const aLines = a.split('\n');
14-
const bLines = b.split('\n');
15-
const diff: number[] = [];
16-
17-
for (let i = 0; i < Math.max(aLines.length, bLines.length); i++) {
18-
if (aLines[i] !== bLines[i]) diff.push(i);
19-
}
20-
21-
return diff;
22-
}
23-
24-
const cancel = Symbol('clack:cancel');
25-
export function isCancel(value: unknown): value is symbol {
26-
return value === cancel;
27-
}
28-
29-
function setRawMode(input: Readable, value: boolean) {
30-
if ((input as typeof stdin).isTTY) (input as typeof stdin).setRawMode(value);
31-
}
8+
import { ALIASES, CANCEL_SYMBOL, diffLines, hasAliasKey, KEYS, setRawMode } from '../utils';
329

33-
const aliases = new Map([
34-
['k', 'up'],
35-
['j', 'down'],
36-
['h', 'left'],
37-
['l', 'right'],
38-
]);
39-
const keys = new Set(['up', 'down', 'left', 'right', 'space', 'enter']);
10+
import type { ClackEvents, ClackState, InferSetType } from '../types';
4011

4112
export interface PromptOptions<Self extends Prompt> {
4213
render(this: Omit<Self, 'prompt'>): string | void;
@@ -48,25 +19,25 @@ export interface PromptOptions<Self extends Prompt> {
4819
debug?: boolean;
4920
}
5021

51-
export type State = 'initial' | 'active' | 'cancel' | 'submit' | 'error';
52-
5322
export default class Prompt {
5423
protected input: Readable;
5524
protected output: Writable;
25+
5626
private rl!: ReadLine;
5727
private opts: Omit<PromptOptions<Prompt>, 'render' | 'input' | 'output'>;
58-
private _track: boolean = false;
5928
private _render: (context: Omit<Prompt, 'prompt'>) => string | void;
60-
protected _cursor: number = 0;
29+
private _track = false;
30+
private _prevFrame = '';
31+
private _subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>();
32+
protected _cursor = 0;
6133

62-
public state: State = 'initial';
34+
public state: ClackState = 'initial';
35+
public error = '';
6336
public value: any;
64-
public error: string = '';
6537

66-
constructor(
67-
{ render, input = stdin, output = stdout, ...opts }: PromptOptions<Prompt>,
68-
trackValue: boolean = true
69-
) {
38+
constructor(options: PromptOptions<Prompt>, trackValue: boolean = true) {
39+
const { input = stdin, output = stdout, render, ...opts } = options;
40+
7041
this.opts = opts;
7142
this.onKeypress = this.onKeypress.bind(this);
7243
this.close = this.close.bind(this);
@@ -78,6 +49,66 @@ export default class Prompt {
7849
this.output = output;
7950
}
8051

52+
/**
53+
* Unsubscribe all listeners
54+
*/
55+
protected unsubscribe() {
56+
this._subscribers.clear();
57+
}
58+
59+
/**
60+
* Set a subscriber with opts
61+
* @param event - The event name
62+
*/
63+
private setSubscriber<T extends keyof ClackEvents>(
64+
event: T,
65+
opts: { cb: ClackEvents[T]; once?: boolean }
66+
) {
67+
const params = this._subscribers.get(event) ?? [];
68+
params.push(opts);
69+
this._subscribers.set(event, params);
70+
}
71+
72+
/**
73+
* Subscribe to an event
74+
* @param event - The event name
75+
* @param cb - The callback
76+
*/
77+
public on<T extends keyof ClackEvents>(event: T, cb: ClackEvents[T]) {
78+
this.setSubscriber(event, { cb });
79+
}
80+
81+
/**
82+
* Subscribe to an event once
83+
* @param event - The event name
84+
* @param cb - The callback
85+
*/
86+
public once<T extends keyof ClackEvents>(event: T, cb: ClackEvents[T]) {
87+
this.setSubscriber(event, { cb, once: true });
88+
}
89+
90+
/**
91+
* Emit an event with data
92+
* @param event - The event name
93+
* @param data - The data to pass to the callback
94+
*/
95+
public emit<T extends keyof ClackEvents>(event: T, ...data: Parameters<ClackEvents[T]>) {
96+
const cbs = this._subscribers.get(event) ?? [];
97+
const cleanup: (() => void)[] = [];
98+
99+
for (const subscriber of cbs) {
100+
subscriber.cb(...data);
101+
102+
if (subscriber.once) {
103+
cleanup.push(() => cbs.splice(cbs.indexOf(subscriber), 1));
104+
}
105+
}
106+
107+
for (const cb of cleanup) {
108+
cb();
109+
}
110+
}
111+
81112
public prompt() {
82113
const sink = new WriteStream(0);
83114
sink._write = (chunk, encoding, done) => {
@@ -120,48 +151,20 @@ export default class Prompt {
120151
this.output.write(cursor.show);
121152
this.output.off('resize', this.render);
122153
setRawMode(this.input, false);
123-
resolve(cancel);
154+
resolve(CANCEL_SYMBOL);
124155
});
125156
});
126157
}
127158

128-
private subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>();
129-
public on(event: string, cb: (...args: any) => any) {
130-
const arr = this.subscribers.get(event) ?? [];
131-
arr.push({ cb });
132-
this.subscribers.set(event, arr);
133-
}
134-
public once(event: string, cb: (...args: any) => any) {
135-
const arr = this.subscribers.get(event) ?? [];
136-
arr.push({ cb, once: true });
137-
this.subscribers.set(event, arr);
138-
}
139-
public emit(event: string, ...data: any[]) {
140-
const cbs = this.subscribers.get(event) ?? [];
141-
const cleanup: (() => void)[] = [];
142-
for (const subscriber of cbs) {
143-
subscriber.cb(...data);
144-
if (subscriber.once) {
145-
cleanup.push(() => cbs.splice(cbs.indexOf(subscriber), 1));
146-
}
147-
}
148-
for (const cb of cleanup) {
149-
cb();
150-
}
151-
}
152-
private unsubscribe() {
153-
this.subscribers.clear();
154-
}
155-
156159
private onKeypress(char: string, key?: Key) {
157160
if (this.state === 'error') {
158161
this.state = 'active';
159162
}
160-
if (key?.name && !this._track && aliases.has(key.name)) {
161-
this.emit('cursor', aliases.get(key.name));
163+
if (key?.name && !this._track && ALIASES.has(key.name)) {
164+
this.emit('cursor', ALIASES.get(key.name));
162165
}
163-
if (key?.name && keys.has(key.name)) {
164-
this.emit('cursor', key.name);
166+
if (key?.name && KEYS.has(key.name as InferSetType<typeof KEYS>)) {
167+
this.emit('cursor', key.name as InferSetType<typeof KEYS>);
165168
}
166169
if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) {
167170
this.emit('confirm', char.toLowerCase() === 'y');
@@ -189,7 +192,8 @@ export default class Prompt {
189192
this.state = 'submit';
190193
}
191194
}
192-
if (char === '\x03') {
195+
196+
if (hasAliasKey([key?.name, key?.sequence], 'cancel')) {
193197
this.state = 'cancel';
194198
}
195199
if (this.state === 'submit' || this.state === 'cancel') {
@@ -217,7 +221,6 @@ export default class Prompt {
217221
this.output.write(cursor.move(-999, lines * -1));
218222
}
219223

220-
private _prevFrame = '';
221224
private render() {
222225
const frame = wrap(this._render(this) ?? '', process.stdout.columns, { hard: true });
223226
if (frame === this._prevFrame) return;

packages/core/src/types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { KEYS } from './utils';
2+
3+
export type InferSetType<T> = T extends Set<infer U> ? U : never;
4+
5+
/**
6+
* The state of the prompt
7+
*/
8+
export type ClackState = 'initial' | 'active' | 'cancel' | 'submit' | 'error';
9+
10+
/**
11+
* Typed event emitter for clack
12+
*/
13+
export interface ClackEvents {
14+
initial: (value?: any) => void;
15+
active: (value?: any) => void;
16+
cancel: (value?: any) => void;
17+
submit: (value?: any) => void;
18+
error: (value?: any) => void;
19+
cursor: (key?: InferSetType<typeof KEYS>) => void;
20+
key: (key?: string) => void;
21+
value: (value?: string) => void;
22+
confirm: (value?: boolean) => void;
23+
finalize: () => void;
24+
}

packages/core/src/utils/aliases.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import type { InferSetType } from '../types';
2+
3+
const DEFAULT_KEYS = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const;
4+
export const KEYS = new Set(DEFAULT_KEYS);
5+
6+
export const ALIASES = new Map<string, InferSetType<typeof KEYS>>([
7+
['k', 'up'],
8+
['j', 'down'],
9+
['h', 'left'],
10+
['l', 'right'],
11+
['\x03', 'cancel'],
12+
]);
13+
14+
/**
15+
* Set custom global aliases for the default keys - This will not overwrite existing aliases just add new ones
16+
*
17+
* @param aliases - A map of aliases to keys
18+
* @default
19+
* new Map([['k', 'up'], ['j', 'down'], ['h', 'left'], ['l', 'right'], ['\x03', 'cancel'],])
20+
*/
21+
export function setGlobalAliases(alias: Array<[string, InferSetType<typeof KEYS>]>) {
22+
for (const [newAlias, key] of alias) {
23+
if (!ALIASES.has(newAlias)) {
24+
ALIASES.set(newAlias, key);
25+
}
26+
}
27+
}
28+
29+
/**
30+
* Check if a key is an alias for a default key
31+
* @param key - The key to check for
32+
* @param type - The type of key to check for
33+
* @returns boolean
34+
*/
35+
export function hasAliasKey(
36+
key: string | Array<string | undefined>,
37+
type: InferSetType<typeof KEYS>
38+
) {
39+
if (typeof key === 'string') {
40+
return ALIASES.has(key) && ALIASES.get(key) === type;
41+
}
42+
43+
return key
44+
.map((n) => {
45+
if (n !== undefined && ALIASES.has(n) && ALIASES.get(n) === type) return true;
46+
return false;
47+
})
48+
.includes(true);
49+
}
Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,27 @@
1-
import type { Key } from 'node:readline';
2-
31
import { stdin, stdout } from 'node:process';
2+
import type { Key } from 'node:readline';
43
import * as readline from 'node:readline';
4+
import type { Readable } from 'node:stream';
55
import { cursor } from 'sisteransi';
6+
import { hasAliasKey } from './aliases';
67

78
const isWindows = globalThis.process.platform.startsWith('win');
89

10+
export * from './aliases';
11+
export * from './string';
12+
13+
export const CANCEL_SYMBOL = Symbol('clack:cancel');
14+
15+
export function isCancel(value: unknown): value is symbol {
16+
return value === CANCEL_SYMBOL;
17+
}
18+
19+
export function setRawMode(input: Readable, value: boolean) {
20+
const i = input as typeof stdin;
21+
22+
if (i.isTTY) i.setRawMode(value);
23+
}
24+
925
export function block({
1026
input = stdin,
1127
output = stdout,
@@ -21,16 +37,16 @@ export function block({
2137
readline.emitKeypressEvents(input, rl);
2238
if (input.isTTY) input.setRawMode(true);
2339

24-
const clear = (data: Buffer, { name }: Key) => {
40+
const clear = (data: Buffer, { name, sequence }: Key) => {
2541
const str = String(data);
26-
if (str === '\x03') {
42+
if (hasAliasKey([str, name, sequence], 'cancel')) {
2743
if (hideCursor) output.write(cursor.show);
2844
process.exit(0);
2945
return;
3046
}
3147
if (!overwrite) return;
32-
let dx = name === 'return' ? 0 : -1;
33-
let dy = name === 'return' ? -1 : 0;
48+
const dx = name === 'return' ? 0 : -1;
49+
const dy = name === 'return' ? -1 : 0;
3450

3551
readline.moveCursor(output, dx, dy, () => {
3652
readline.clearLine(output, 1, () => {

packages/core/src/utils/string.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function diffLines(a: string, b: string) {
2+
if (a === b) return;
3+
4+
const aLines = a.split('\n');
5+
const bLines = b.split('\n');
6+
const diff: number[] = [];
7+
8+
for (let i = 0; i < Math.max(aLines.length, bLines.length); i++) {
9+
if (aLines[i] !== bLines[i]) diff.push(i);
10+
}
11+
12+
return diff;
13+
}

0 commit comments

Comments
 (0)