diff --git a/.travis.yml b/.travis.yml
index 813ea903..e735877f 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -3,5 +3,6 @@ script:
- npm run build
- npm test
node_js:
+ - "stable"
- "8"
- "6"
diff --git a/lib/elements/multiselect.js b/lib/elements/multiselect.js
index afa2ca23..1f87082d 100644
--- a/lib/elements/multiselect.js
+++ b/lib/elements/multiselect.js
@@ -30,6 +30,7 @@ class MultiselectPrompt extends Prompt {
this.showMinError = false;
this.maxChoices = opts.max;
this.instructions = opts.instructions;
+ this.hotkeys = opts.hotkeys || {};
this.optionsPerPage = opts.optionsPerPage || 10;
this.value = opts.choices.map((ch, idx) => {
if (typeof ch === 'string')
@@ -150,11 +151,27 @@ class MultiselectPrompt extends Prompt {
this.render();
}
- _(c, key) {
+ _(c) {
if (c === ' ') {
this.handleSpaceToggle();
} else if (c === 'a') {
this.toggleAll();
+ } else if (Object.keys(this.hotkeys).includes(c)) {
+ const hotkeyResponse = this.hotkeys[c].handle() || {};
+ if (hotkeyResponse.answers) {
+ this.value.forEach(value => {
+ const answerForValue = hotkeyResponse.answers[value.title];
+ if (answerForValue !== undefined) {
+ value.selected = answerForValue;
+ this.render();
+ }
+ });
+ }
+
+ if (['abort', 'submit'].includes(hotkeyResponse.command)) {
+ this[hotkeyResponse.command]();
+ }
+
} else {
return this.bell();
}
@@ -165,10 +182,17 @@ class MultiselectPrompt extends Prompt {
if (typeof this.instructions === 'string') {
return this.instructions;
}
+
+ let hotkeyInstructions = Object.keys(this.hotkeys)
+ .map(key => [key, this.hotkeys[key]])
+ .reduce((instructions, [hotkeyChar, hotkeyDescriptor]) =>
+ instructions += ` ${hotkeyChar}: ${hotkeyDescriptor.instruction}\n`, '')
+
return '\nInstructions:\n'
+ ` ${figures.arrowUp}/${figures.arrowDown}: Highlight option\n`
+ ` ${figures.arrowLeft}/${figures.arrowRight}/[space]: Toggle selection\n`
+ (this.maxChoices === undefined ? ` a: Toggle all\n` : '')
+ + hotkeyInstructions
+ ` enter/return: Complete answer`;
}
return '';
diff --git a/package.json b/package.json
index ca0c1f2b..c0c67c01 100644
--- a/package.json
+++ b/package.json
@@ -19,7 +19,7 @@
"start": "node lib/index.js",
"build": "babel lib -d dist",
"prepublishOnly": "npm run build",
- "test": "tape test/*.js | tap-spec"
+ "test": "npm run build && tape test/*.js | tap-spec"
},
"keywords": [
"ui",
diff --git a/readme.md b/readme.md
index 2f6c5b6a..f9db2e85 100755
--- a/readme.md
+++ b/readme.md
@@ -1,5 +1,5 @@
-
+
❯ Prompts
@@ -36,7 +36,7 @@
* **Unified**: consistent experience across all [prompts](#-types).
-![split](https://github.com/terkelg/prompts/raw/master/media/split.png)
+![split](./media/split.png)
## ❯ Install
@@ -47,11 +47,11 @@ $ npm install --save prompts
> This package supports Node 6 and above
-![split](https://github.com/terkelg/prompts/raw/master/media/split.png)
+![split](./media/split.png)
## ❯ Usage
-
+
```js
const prompts = require('prompts');
@@ -71,7 +71,7 @@ const prompts = require('prompts');
> See [`example.js`](https://github.com/terkelg/prompts/blob/master/example.js) for more options.
-![split](https://github.com/terkelg/prompts/raw/master/media/split.png)
+![split](./media/split.png)
## ❯ Examples
@@ -155,7 +155,7 @@ const questions = [
```
-![split](https://github.com/terkelg/prompts/raw/master/media/split.png)
+![split](./media/split.png)
## ❯ API
@@ -299,7 +299,7 @@ prompts.inject([ '@terkelg', ['#ff0000', '#0000ff'] ]);
})();
```
-![split](https://github.com/terkelg/prompts/raw/master/media/split.png)
+![split](./media/split.png)
## ❯ Prompt Objects
@@ -418,7 +418,7 @@ The function signature is `(state)` where `state` is an object with a snapshot o
The state object has two properties `value` and `aborted`. E.g `{ value: 'This is ', aborted: false }`
-![split](https://github.com/terkelg/prompts/raw/master/media/split.png)
+![split](./media/split.png)
## ❯ Types
@@ -444,7 +444,7 @@ The state object has two properties `value` and `aborted`. E.g `{ value: 'This i
Hit tab to autocomplete to `initial` value when provided.
#### Example
-
+
```js
{
@@ -475,7 +475,7 @@ Hit tab to autocomplete to `initial` value when provided.
This prompt is a similar to a prompt of type `'text'` with `style` set to `'password'`.
#### Example
-
+
```js
{
@@ -506,7 +506,7 @@ This prompt is working like `sudo` where the input is invisible.
This prompt is a similar to a prompt of type `'text'` with style set to `'invisible'`.
#### Example
-
+
```js
{
@@ -536,7 +536,7 @@ This prompt is a similar to a prompt of type `'text'` with style set to `'invisi
You can type in numbers and use up/down to increase/decrease the value. Only numbers are allowed as input. Hit tab to autocomplete to `initial` value when provided.
#### Example
-
+
```js
{
@@ -576,7 +576,7 @@ You can type in numbers and use up/down to increase/decrea
Hit y or n to confirm/reject.
#### Example
-
+
```js
{
@@ -617,7 +617,7 @@ string separated by `separator`.
}
```
-
+
| Param | Type | Description |
@@ -639,7 +639,7 @@ string separated by `separator`.
Use tab or arrow keys/tab/space to switch between options.
#### Example
-
+
```js
{
@@ -673,7 +673,7 @@ Use tab or arrow keys/tab/space to switch betwe
Use up/down to navigate. Use tab to cycle the list.
#### Example
-
+
```js
{
@@ -714,7 +714,7 @@ Use space to toggle select/unselect and up/down
By default this prompt returns an `array` containing the **values** of the selected items - not their display title.
#### Example
-
+
```js
{
@@ -727,6 +727,17 @@ By default this prompt returns an `array` containing the **values** of the selec
{ title: 'Blue', value: '#0000ff', selected: true }
],
max: 2,
+ hotkeys: {
+ d: {
+ handle() {
+ return {
+ answer: {Red: true, Green: false},
+ command: 'submit'
+ }
+ },
+ instruction: 'Enable red, disable green, and submit the answer.'
+ }
+ }
hint: '- Space to select. Return to submit'
}
```
@@ -745,10 +756,35 @@ By default this prompt returns an `array` containing the **values** of the selec
| warn | `string` | Message to display when selecting a disabled option |
| onRender | `function` | On render callback. Keyword `this` refers to the current prompt |
| onState | `function` | On state change callback. Function signature is an `object` with two properties: `value` and `aborted` |
+| hotkeys | `{[hotkeyCharacter]: hotkeyDescriptor}` | Define hotkeys that can select multiple answers at once. |
This is one of the few prompts that don't take a initial value.
If you want to predefine selected values, give the choice object an `selected` property of `true`.
+The type of `hotkeyDescriptor` is:
+
+```ts
+{
+ // You can do whatever you want in this function (e.g. trigger a side effect). You can also customize the choices object.
+ handle: () => {
+
+ // Pass "submit" to have this question be submitted when the user presses the hotkey.
+ // Pass "abort" to have the question be aborted (as if the user had hit control+c).
+ command?: 'submit' | 'abort',
+
+ answers: {
+ // The key in this object is the `title` field in the `choices` array passed above.
+ // The value is whether this choice will be selected after the hotkey is pressed.
+ // Pass true to select the choice. False deselects the choice. Undefined leaves the choice as-is.
+ [answerTitle]: boolean
+ }
+ },
+ instruction: string
+}
+```
+
+See the [multiselect test fixture](./test/fixtures/multiselect.js) for more examples of using hotkeys.
+
**↑ back to:** [Prompt types](#-types)
***
@@ -763,7 +799,7 @@ The default suggests function is sorting based on the `title` property of the ch
You can overwrite how choices are being filtered by passing your own suggest function.
#### Example
-
+
```js
{
@@ -810,7 +846,7 @@ const suggestByTitle = (input, choices) =>
Use left/right/tab to navigate. Use up/down to change date.
#### Example
-
+
```js
{
@@ -835,7 +871,7 @@ Use left/right/tab to navigate. Use up**Formatting**: See full list of formatting options in the [wiki](https://github.com/terkelg/prompts/wiki/Date-Time-Formatting)
-![split](https://github.com/terkelg/prompts/raw/master/media/split.png)
+![split](./media/split.png)
**↑ back to:** [Prompt types](#-types)
diff --git a/test/fixtures/multiselect.js b/test/fixtures/multiselect.js
new file mode 100755
index 00000000..dfdd5d95
--- /dev/null
+++ b/test/fixtures/multiselect.js
@@ -0,0 +1,40 @@
+const prompts = require('../..');
+
+prompts([
+ {
+ type: 'multiselect',
+ name: 'color',
+ message: 'Pick colors',
+ choices: [
+ { title: 'Red', value: '#ff0000' },
+ { title: 'Green', value: '#00ff00' },
+ { title: 'Blue', value: '#0000ff' }
+ ],
+ hotkeys: {
+ r: {
+ handle() {
+ return {answers: {Red: true, Green: true}};
+ },
+ instruction: 'Choose Red and Green'
+ },
+ d: {
+ handle() {
+ return {command: 'abort'};
+ },
+ instruction: 'Abort'
+ },
+ e: {
+ handle() {
+ return {answers: {Green: true, Blue: true}, command: 'submit'}
+ },
+ instruction: 'Select Green and Blue, and move on the to the next question'
+ },
+ f: {
+ handle() {
+ return {answers: {Red: false, Blue: true}, command: 'submit'}
+ },
+ instruction: 'Pay respect. Also, enable Blue and disable Red.'
+ }
+ }
+ }
+]).then(response => console.error(JSON.stringify(response, null, 2)));
diff --git a/test/multiselect.js b/test/multiselect.js
new file mode 100644
index 00000000..9d6a61f3
--- /dev/null
+++ b/test/multiselect.js
@@ -0,0 +1,130 @@
+const child_process = require('child_process');
+const multiselectFixture = require.resolve('./fixtures/multiselect');
+const readline = require('readline');
+
+const test = require('tape');
+
+// Running these tests will make your terminal cursor disappear. Sorry. Run `reset` to fix it.
+
+const awaitOutput = (pipe, output) => new Promise((resolve) => {
+ let allDataFromPipe = '';
+ pipe.on('data', dataFromPipe => {
+ allDataFromPipe += dataFromPipe;
+ if (allDataFromPipe.toString().includes(output)) {
+ resolve(allDataFromPipe);
+ }
+ });
+});
+
+const spawn = (inputs) => {
+ const childProcess = child_process.spawn('node', [multiselectFixture]);
+ const {stdout, stderr, stdin} = childProcess;
+
+ // Pass this env var to get some visibility into what the subproc is doing, and also kinda break the tests.
+ // But maybe it'll help you. :)
+ if (process.env.DEBUG) {
+ stdout.pipe(process.stdout);
+ process.stdin.pipe(stdin);
+ }
+
+ childProcess.on('error', (err) => {
+ console.log('Spawn error:', err);
+ });
+
+ const readlineInstance = readline.createInterface({output: stdin, input: stdin, terminal: true });
+
+ const stderrPromise = new Promise((resolve, reject) => {
+ try {
+ stderr.on('data', data => {
+ childProcess.kill();
+ stdout.unpipe(process.stdout);
+ // I hope the JSON output always comes as a single data event.
+ resolve(JSON.parse(data.toString()));
+ })
+ } catch (e) {
+ reject(e);
+ }
+ })
+
+ const stdoutPromise = awaitOutput(stdout, 'Blue').then(allDataFromPipe => {
+ inputs.forEach(input => {
+ if (typeof input === 'string') {
+ readlineInstance.write(input);
+ } else if (typeof input === 'object') {
+ readlineInstance.write(undefined, input);
+ } else {
+ throw new Error(
+ 'Argument error: every element of spawn()s input array should be a string or an object, ' +
+ `but "${input}" was passed.`);
+ }
+ })
+ return allDataFromPipe;
+ });
+
+ return Promise.all([stderrPromise, stdoutPromise]).then(([childProcessResponse, childProcessStdout]) => ({
+ response: childProcessResponse,
+ stdout: childProcessStdout
+ }));
+}
+
+test('multiselect "a"', t => {
+ t.plan(1);
+ spawn(['a', {name: 'enter'}]).then(({response}) => {
+ t.deepEqual(response, {
+ color: ['#ff0000', '#00ff00', '#0000ff']
+ }, 'pressing "a" selects all options');
+ t.end();
+ });
+})
+
+test('multiselect hotkey that selects multiple answers', t => {
+ t.plan(2);
+ spawn(['r', {name: 'enter'}]).then(({response, stdout}) => {
+ t.ok(stdout.includes('r: Choose Red and Green'), `Stdout includes hotkey instructions`);
+
+ t.deepEqual(response, {
+ color: ['#ff0000', '#00ff00']
+ }, 'pressing hotkey selects the right answers');
+ t.end();
+ });
+})
+
+test('multiselect hotkey that aborts', t => {
+ t.plan(2);
+ spawn(['d']).then(({response, stdout}) => {
+ t.ok(stdout.includes('d: Abort'), `Stdout includes hotkey instructions`);
+
+ t.deepEqual(response, {}, 'pressing hotkey aborts the process');
+ t.end();
+ });
+})
+
+test('multiselect hotkey that chooses answers and submits', t => {
+ t.plan(2);
+ spawn(['e']).then(({response, stdout}) => {
+ t.ok(
+ stdout.includes('e: Select Green and Blue, and move on the to the next question'),
+ `Stdout includes hotkey instructions`
+ );
+
+ t.deepEqual(response, {
+ color: ['#00ff00', '#0000ff']
+ }, 'pressing hotkey chooses answers and submits');
+ t.end();
+ });
+})
+
+test('multiselect hotkey that chooses answers and submits', t => {
+ t.plan(2);
+ spawn(['a', 'f']).then(({response, stdout}) => {
+ t.ok(
+ stdout.includes('f: Pay respect. Also, enable Blue and disable Red.'),
+ `Stdout includes hotkey instructions`
+ );
+
+ t.deepEqual(response, {
+ color: ['#00ff00', '#0000ff']
+ }, 'pressing hotkey chooses answers and submits');
+ t.end();
+ });
+})