Skip to content

Commit 85cc1d5

Browse files
cpreston321natemoo-re
authored andcommitted
feat: improve types event emitter & global aliases
1 parent 2bbd33e commit 85cc1d5

File tree

6 files changed

+156
-52
lines changed

6 files changed

+156
-52
lines changed

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: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@ 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, isCancel, type State } 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 { block, setGlobalAliases } from './utils';

packages/core/src/prompts/prompt.ts

Lines changed: 93 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { WriteStream } from 'node:tty';
77
import { cursor, erase } from 'sisteransi';
88
import wrap from 'wrap-ansi';
99

10+
import { type InferSetType, aliases, keys, hasAliasKey } from '../utils';
11+
1012
function diffLines(a: string, b: string) {
1113
if (a === b) return;
1214

@@ -30,14 +32,6 @@ function setRawMode(input: Readable, value: boolean) {
3032
if ((input as typeof stdin).isTTY) (input as typeof stdin).setRawMode(value);
3133
}
3234

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']);
40-
4135
export interface PromptOptions<Self extends Prompt> {
4236
render(this: Omit<Self, 'prompt'>): string | void;
4337
placeholder?: string;
@@ -50,23 +44,49 @@ export interface PromptOptions<Self extends Prompt> {
5044

5145
export type State = 'initial' | 'active' | 'cancel' | 'submit' | 'error';
5246

47+
/**
48+
* Typed event emitter for clack
49+
*/
50+
interface ClackHooks {
51+
'initial': (value?: any) => void;
52+
'active': (value?: any) => void;
53+
'cancel': (value?: any) => void;
54+
'submit': (value?: any) => void;
55+
'error': (value?: any) => void;
56+
'cursor': (key?: InferSetType<typeof keys>) => void;
57+
'key': (key?: string) => void;
58+
'value': (value?: string) => void;
59+
'confirm': (value?: boolean) => void;
60+
'finalize': () => void;
61+
}
62+
5363
export default class Prompt {
5464
protected input: Readable;
5565
protected output: Writable;
66+
5667
private rl!: ReadLine;
5768
private opts: Omit<PromptOptions<Prompt>, 'render' | 'input' | 'output'>;
58-
private _track: boolean = false;
5969
private _render: (context: Omit<Prompt, 'prompt'>) => string | void;
60-
protected _cursor: number = 0;
70+
private _track = false;
71+
private _prevFrame = '';
72+
private _subscribers = new Map<string, { cb: (...args: any) => any; once?: boolean }[]>();
73+
protected _cursor = 0;
6174

6275
public state: State = 'initial';
76+
public error = '';
6377
public value: any;
64-
public error: string = '';
6578

6679
constructor(
67-
{ render, input = stdin, output = stdout, ...opts }: PromptOptions<Prompt>,
80+
options: PromptOptions<Prompt>,
6881
trackValue: boolean = true
6982
) {
83+
const {
84+
input = stdin,
85+
output = stdout,
86+
render,
87+
...opts
88+
} = options;
89+
7090
this.opts = opts;
7191
this.onKeypress = this.onKeypress.bind(this);
7292
this.close = this.close.bind(this);
@@ -78,6 +98,63 @@ export default class Prompt {
7898
this.output = output;
7999
}
80100

101+
/**
102+
* Unsubscribe all listeners
103+
*/
104+
protected unsubscribe() {
105+
this._subscribers.clear();
106+
}
107+
108+
/**
109+
* Set a subscriber with opts
110+
* @param event - The event name
111+
*/
112+
private setSubscriber<T extends keyof ClackHooks>(event: T, opts: { cb: ClackHooks[T]; once?: boolean }) {
113+
const params = this._subscribers.get(event) ?? [];
114+
params.push(opts);
115+
this._subscribers.set(event, params);
116+
}
117+
118+
/**
119+
* Subscribe to an event
120+
* @param event - The event name
121+
* @param cb - The callback
122+
*/
123+
public on<T extends keyof ClackHooks>(event: T, cb: ClackHooks[T]) {
124+
this.setSubscriber(event, { cb });
125+
}
126+
127+
/**
128+
* Subscribe to an event once
129+
* @param event - The event name
130+
* @param cb - The callback
131+
*/
132+
public once<T extends keyof ClackHooks>(event: T, cb: ClackHooks[T]) {
133+
this.setSubscriber(event, { cb, once: true });
134+
}
135+
136+
/**
137+
* Emit an event with data
138+
* @param event - The event name
139+
* @param data - The data to pass to the callback
140+
*/
141+
public emit<T extends keyof ClackHooks>(event: T, ...data: Parameters<ClackHooks[T]>) {
142+
const cbs = this._subscribers.get(event) ?? [];
143+
const cleanup: (() => void)[] = [];
144+
145+
for (const subscriber of cbs) {
146+
subscriber.cb(...data);
147+
148+
if (subscriber.once) {
149+
cleanup.push(() => cbs.splice(cbs.indexOf(subscriber), 1));
150+
}
151+
}
152+
153+
for (const cb of cleanup) {
154+
cb();
155+
}
156+
}
157+
81158
public prompt() {
82159
const sink = new WriteStream(0);
83160
sink._write = (chunk, encoding, done) => {
@@ -125,43 +202,15 @@ export default class Prompt {
125202
});
126203
}
127204

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-
156205
private onKeypress(char: string, key?: Key) {
157206
if (this.state === 'error') {
158207
this.state = 'active';
159208
}
160209
if (key?.name && !this._track && aliases.has(key.name)) {
161210
this.emit('cursor', aliases.get(key.name));
162211
}
163-
if (key?.name && keys.has(key.name)) {
164-
this.emit('cursor', key.name);
212+
if (key?.name && keys.has(key.name as InferSetType<typeof keys>)) {
213+
this.emit('cursor', key.name as InferSetType<typeof keys>);
165214
}
166215
if (char && (char.toLowerCase() === 'y' || char.toLowerCase() === 'n')) {
167216
this.emit('confirm', char.toLowerCase() === 'y');
@@ -189,7 +238,8 @@ export default class Prompt {
189238
this.state = 'submit';
190239
}
191240
}
192-
if (char === '\x03') {
241+
242+
if (hasAliasKey([key?.name, key?.sequence], 'cancel')) {
193243
this.state = 'cancel';
194244
}
195245
if (this.state === 'submit' || this.state === 'cancel') {
@@ -217,7 +267,6 @@ export default class Prompt {
217267
this.output.write(cursor.move(-999, lines * -1));
218268
}
219269

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

packages/core/src/utils/aliases.ts

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

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

@@ -21,16 +22,16 @@ export function block({
2122
readline.emitKeypressEvents(input, rl);
2223
if (input.isTTY) input.setRawMode(true);
2324

24-
const clear = (data: Buffer, { name }: Key) => {
25+
const clear = (data: Buffer, { name, sequence }: Key) => {
2526
const str = String(data);
26-
if (str === '\x03') {
27+
if (hasAliasKey([str, name, sequence], 'cancel')) {
2728
if (hideCursor) output.write(cursor.show);
2829
process.exit(0);
2930
return;
3031
}
3132
if (!overwrite) return;
32-
let dx = name === 'return' ? 0 : -1;
33-
let dy = name === 'return' ? -1 : 0;
33+
const dx = name === 'return' ? 0 : -1;
34+
const dy = name === 'return' ? -1 : 0;
3435

3536
readline.moveCursor(output, dx, dy, () => {
3637
readline.clearLine(output, 1, () => {
@@ -53,3 +54,5 @@ export function block({
5354
rl.close();
5455
};
5556
}
57+
58+
export * from './aliases';

packages/prompts/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import isUnicodeSupported from 'is-unicode-supported';
1414
import color from 'picocolors';
1515
import { cursor, erase } from 'sisteransi';
1616

17-
export { isCancel } from '@clack/core';
17+
export { isCancel, setGlobalAliases } from '@clack/core';
1818

1919
const unicode = isUnicodeSupported();
2020
const s = (c: string, fallback: string) => (unicode ? c : fallback);

0 commit comments

Comments
 (0)