Skip to content

Commit b0fa7d8

Browse files
authored
fix: wrapping in multi-selects (#411)
1 parent 7530af0 commit b0fa7d8

File tree

4 files changed

+298
-13
lines changed

4 files changed

+298
-13
lines changed

.changeset/fine-swans-retire.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@clack/prompts": patch
3+
---
4+
5+
Add support for wrapped messages in multi line prompts

packages/prompts/src/multi-select.ts

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { MultiSelectPrompt } from '@clack/core';
1+
import { MultiSelectPrompt, wrapTextWithPrefix } from '@clack/core';
22
import color from 'picocolors';
33
import {
44
type CommonOptions,
@@ -8,6 +8,7 @@ import {
88
S_CHECKBOX_INACTIVE,
99
S_CHECKBOX_SELECTED,
1010
symbol,
11+
symbolBar,
1112
} from './common.js';
1213
import { limitOptions } from './limit-options.js';
1314
import type { Option } from './select.js';
@@ -20,6 +21,13 @@ export interface MultiSelectOptions<Value> extends CommonOptions {
2021
required?: boolean;
2122
cursorAt?: Value;
2223
}
24+
const computeLabel = (label: string, format: (text: string) => string) => {
25+
return label
26+
.split('\n')
27+
.map((line) => format(line))
28+
.join('\n');
29+
};
30+
2331
export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
2432
const opt = (
2533
option: Option<Value>,
@@ -34,7 +42,7 @@ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
3442
) => {
3543
const label = option.label ?? String(option.value);
3644
if (state === 'disabled') {
37-
return `${color.gray(S_CHECKBOX_INACTIVE)} ${color.gray(label)}${
45+
return `${color.gray(S_CHECKBOX_INACTIVE)} ${computeLabel(label, color.gray)}${
3846
option.hint ? ` ${color.dim(`(${option.hint ?? 'disabled'})`)}` : ''
3947
}`;
4048
}
@@ -44,22 +52,22 @@ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
4452
}`;
4553
}
4654
if (state === 'selected') {
47-
return `${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}${
55+
return `${color.green(S_CHECKBOX_SELECTED)} ${computeLabel(label, color.dim)}${
4856
option.hint ? ` ${color.dim(`(${option.hint})`)}` : ''
4957
}`;
5058
}
5159
if (state === 'cancelled') {
52-
return `${color.strikethrough(color.dim(label))}`;
60+
return `${computeLabel(label, (text) => color.strikethrough(color.dim(text)))}`;
5361
}
5462
if (state === 'active-selected') {
5563
return `${color.green(S_CHECKBOX_SELECTED)} ${label}${
5664
option.hint ? ` ${color.dim(`(${option.hint})`)}` : ''
5765
}`;
5866
}
5967
if (state === 'submitted') {
60-
return `${color.dim(label)}`;
68+
return `${computeLabel(label, color.dim)}`;
6169
}
62-
return `${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`;
70+
return `${color.dim(S_CHECKBOX_INACTIVE)} ${computeLabel(label, color.dim)}`;
6371
};
6472
const required = opts.required ?? true;
6573

@@ -82,7 +90,13 @@ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
8290
)}`;
8391
},
8492
render() {
85-
const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
93+
const wrappedMessage = wrapTextWithPrefix(
94+
opts.output,
95+
opts.message,
96+
`${symbolBar(this.state)} `,
97+
`${symbol(this.state)} `
98+
);
99+
const title = `${color.gray(S_BAR)}\n${wrappedMessage}\n`;
86100
const value = this.value ?? [];
87101

88102
const styleOption = (option: Option<Value>, active: boolean) => {
@@ -101,21 +115,28 @@ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
101115

102116
switch (this.state) {
103117
case 'submit': {
104-
return `${title}${color.gray(S_BAR)} ${
118+
const submitText =
105119
this.options
106120
.filter(({ value: optionValue }) => value.includes(optionValue))
107121
.map((option) => opt(option, 'submitted'))
108-
.join(color.dim(', ')) || color.dim('none')
109-
}`;
122+
.join(color.dim(', ')) || color.dim('none');
123+
const wrappedSubmitText = wrapTextWithPrefix(
124+
opts.output,
125+
submitText,
126+
`${color.gray(S_BAR)} `
127+
);
128+
return `${title}${wrappedSubmitText}`;
110129
}
111130
case 'cancel': {
112131
const label = this.options
113132
.filter(({ value: optionValue }) => value.includes(optionValue))
114133
.map((option) => opt(option, 'cancelled'))
115134
.join(color.dim(', '));
116-
return `${title}${color.gray(S_BAR)}${
117-
label.trim() ? ` ${label}\n${color.gray(S_BAR)}` : ''
118-
}`;
135+
if (label.trim() === '') {
136+
return `${title}${color.gray(S_BAR)}`;
137+
}
138+
const wrappedLabel = wrapTextWithPrefix(opts.output, label, `${color.gray(S_BAR)} `);
139+
return `${title}${wrappedLabel}\n${color.gray(S_BAR)}`;
119140
}
120141
case 'error': {
121142
const prefix = `${color.yellow(S_BAR)} `;

packages/prompts/test/__snapshots__/multi-select.test.ts.snap

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,104 @@ exports[`multiselect (isCI = false) > sliding window loops upwards 1`] = `
636636
]
637637
`;
638638
639+
exports[`multiselect (isCI = false) > wraps cancelled state with long options 1`] = `
640+
[
641+
"<cursor.hide>",
642+
"│
643+
◆ foo
644+
│ ◻ Option 0 Option 0 Option
645+
│ 0 Option 0 Option 0 Option
646+
│ 0 Option 0 Option 0 Option
647+
│ 0 Option 0
648+
│ ◻ Option 1 Option 1 Option 
649+
│ 1 Option 1 Option 1 Option 
650+
│ 1 Option 1 Option 1 Option 
651+
│ 1 Option 1
652+
└
653+
",
654+
"<cursor.backward count=999><cursor.up count=11>",
655+
"<cursor.down count=2>",
656+
"<erase.line><cursor.left count=1>",
657+
"│ ◼ Option 0 Option 0 Option ",
658+
"<cursor.down count=9>",
659+
"<cursor.backward count=999><cursor.up count=11>",
660+
"<cursor.down count=1>",
661+
"<erase.down>",
662+
"■ foo
663+
│ Option 0 Option 0 Option 0 
664+
│ Option 0 Option 0 Option 0 
665+
│ Option 0 Option 0 Option 0 
666+
│ Option 0
667+
│",
668+
"
669+
",
670+
"<cursor.show>",
671+
]
672+
`;
673+
674+
exports[`multiselect (isCI = false) > wraps long messages 1`] = `
675+
[
676+
"<cursor.hide>",
677+
"│
678+
◆ foo foo foo foo foo foo foo
679+
│ foo foo foo foo foo foo
680+
│ foo foo foo foo foo foo foo
681+
│ ◻ opt0
682+
│ ◻ opt1
683+
└
684+
",
685+
"<cursor.backward count=999><cursor.up count=7>",
686+
"<cursor.down count=4>",
687+
"<erase.line><cursor.left count=1>",
688+
"│ ◼ opt0",
689+
"<cursor.down count=3>",
690+
"<cursor.backward count=999><cursor.up count=7>",
691+
"<cursor.down count=1>",
692+
"<erase.down>",
693+
"◇ foo foo foo foo foo foo foo
694+
│ foo foo foo foo foo foo
695+
│ foo foo foo foo foo foo foo
696+
│ opt0",
697+
"
698+
",
699+
"<cursor.show>",
700+
]
701+
`;
702+
703+
exports[`multiselect (isCI = false) > wraps success state with long options 1`] = `
704+
[
705+
"<cursor.hide>",
706+
"│
707+
◆ foo
708+
│ ◻ Option 0 Option 0 Option
709+
│ 0 Option 0 Option 0 Option
710+
│ 0 Option 0 Option 0 Option
711+
│ 0 Option 0
712+
│ ◻ Option 1 Option 1 Option 
713+
│ 1 Option 1 Option 1 Option 
714+
│ 1 Option 1 Option 1 Option 
715+
│ 1 Option 1
716+
└
717+
",
718+
"<cursor.backward count=999><cursor.up count=11>",
719+
"<cursor.down count=2>",
720+
"<erase.line><cursor.left count=1>",
721+
"│ ◼ Option 0 Option 0 Option ",
722+
"<cursor.down count=9>",
723+
"<cursor.backward count=999><cursor.up count=11>",
724+
"<cursor.down count=1>",
725+
"<erase.down>",
726+
"◇ foo
727+
│ Option 0 Option 0 Option 0 
728+
│ Option 0 Option 0 Option 0 
729+
│ Option 0 Option 0 Option 0 
730+
│ Option 0",
731+
"
732+
",
733+
"<cursor.show>",
734+
]
735+
`;
736+
639737
exports[`multiselect (isCI = true) > can be aborted by a signal 1`] = `
640738
[
641739
"<cursor.hide>",
@@ -1271,3 +1369,101 @@ exports[`multiselect (isCI = true) > sliding window loops upwards 1`] = `
12711369
"<cursor.show>",
12721370
]
12731371
`;
1372+
1373+
exports[`multiselect (isCI = true) > wraps cancelled state with long options 1`] = `
1374+
[
1375+
"<cursor.hide>",
1376+
"│
1377+
◆ foo
1378+
│ ◻ Option 0 Option 0 Option
1379+
│ 0 Option 0 Option 0 Option
1380+
│ 0 Option 0 Option 0 Option
1381+
│ 0 Option 0
1382+
│ ◻ Option 1 Option 1 Option 
1383+
│ 1 Option 1 Option 1 Option 
1384+
│ 1 Option 1 Option 1 Option 
1385+
│ 1 Option 1
1386+
└
1387+
",
1388+
"<cursor.backward count=999><cursor.up count=11>",
1389+
"<cursor.down count=2>",
1390+
"<erase.line><cursor.left count=1>",
1391+
"│ ◼ Option 0 Option 0 Option ",
1392+
"<cursor.down count=9>",
1393+
"<cursor.backward count=999><cursor.up count=11>",
1394+
"<cursor.down count=1>",
1395+
"<erase.down>",
1396+
"■ foo
1397+
│ Option 0 Option 0 Option 0 
1398+
│ Option 0 Option 0 Option 0 
1399+
│ Option 0 Option 0 Option 0 
1400+
│ Option 0
1401+
│",
1402+
"
1403+
",
1404+
"<cursor.show>",
1405+
]
1406+
`;
1407+
1408+
exports[`multiselect (isCI = true) > wraps long messages 1`] = `
1409+
[
1410+
"<cursor.hide>",
1411+
"│
1412+
◆ foo foo foo foo foo foo foo
1413+
│ foo foo foo foo foo foo
1414+
│ foo foo foo foo foo foo foo
1415+
│ ◻ opt0
1416+
│ ◻ opt1
1417+
└
1418+
",
1419+
"<cursor.backward count=999><cursor.up count=7>",
1420+
"<cursor.down count=4>",
1421+
"<erase.line><cursor.left count=1>",
1422+
"│ ◼ opt0",
1423+
"<cursor.down count=3>",
1424+
"<cursor.backward count=999><cursor.up count=7>",
1425+
"<cursor.down count=1>",
1426+
"<erase.down>",
1427+
"◇ foo foo foo foo foo foo foo
1428+
│ foo foo foo foo foo foo
1429+
│ foo foo foo foo foo foo foo
1430+
│ opt0",
1431+
"
1432+
",
1433+
"<cursor.show>",
1434+
]
1435+
`;
1436+
1437+
exports[`multiselect (isCI = true) > wraps success state with long options 1`] = `
1438+
[
1439+
"<cursor.hide>",
1440+
"│
1441+
◆ foo
1442+
│ ◻ Option 0 Option 0 Option
1443+
│ 0 Option 0 Option 0 Option
1444+
│ 0 Option 0 Option 0 Option
1445+
│ 0 Option 0
1446+
│ ◻ Option 1 Option 1 Option 
1447+
│ 1 Option 1 Option 1 Option 
1448+
│ 1 Option 1 Option 1 Option 
1449+
│ 1 Option 1
1450+
└
1451+
",
1452+
"<cursor.backward count=999><cursor.up count=11>",
1453+
"<cursor.down count=2>",
1454+
"<erase.line><cursor.left count=1>",
1455+
"│ ◼ Option 0 Option 0 Option ",
1456+
"<cursor.down count=9>",
1457+
"<cursor.backward count=999><cursor.up count=11>",
1458+
"<cursor.down count=1>",
1459+
"<erase.down>",
1460+
"◇ foo
1461+
│ Option 0 Option 0 Option 0 
1462+
│ Option 0 Option 0 Option 0 
1463+
│ Option 0 Option 0 Option 0 
1464+
│ Option 0",
1465+
"
1466+
",
1467+
"<cursor.show>",
1468+
]
1469+
`;

packages/prompts/test/multi-select.test.ts

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,4 +336,67 @@ describe.each(['true', 'false'])('multiselect (isCI = %s)', (isCI) => {
336336
expect(value).toEqual(['opt1']);
337337
expect(output.buffer).toMatchSnapshot();
338338
});
339+
340+
test('wraps long messages', async () => {
341+
output.columns = 40;
342+
343+
const result = prompts.multiselect({
344+
message: 'foo '.repeat(20).trim(),
345+
options: [{ value: 'opt0' }, { value: 'opt1' }],
346+
input,
347+
output,
348+
});
349+
350+
input.emit('keypress', '', { name: 'space' });
351+
input.emit('keypress', '', { name: 'return' });
352+
353+
const value = await result;
354+
355+
expect(value).toEqual(['opt0']);
356+
expect(output.buffer).toMatchSnapshot();
357+
});
358+
359+
test('wraps cancelled state with long options', async () => {
360+
output.columns = 40;
361+
362+
const result = prompts.multiselect({
363+
message: 'foo',
364+
options: [
365+
{ value: 'opt0', label: 'Option 0 '.repeat(10).trim() },
366+
{ value: 'opt1', label: 'Option 1 '.repeat(10).trim() },
367+
],
368+
input,
369+
output,
370+
});
371+
372+
input.emit('keypress', '', { name: 'space' });
373+
input.emit('keypress', 'escape', { name: 'escape' });
374+
375+
const value = await result;
376+
377+
expect(prompts.isCancel(value)).toBe(true);
378+
expect(output.buffer).toMatchSnapshot();
379+
});
380+
381+
test('wraps success state with long options', async () => {
382+
output.columns = 40;
383+
384+
const result = prompts.multiselect({
385+
message: 'foo',
386+
options: [
387+
{ value: 'opt0', label: 'Option 0 '.repeat(10).trim() },
388+
{ value: 'opt1', label: 'Option 1 '.repeat(10).trim() },
389+
],
390+
input,
391+
output,
392+
});
393+
394+
input.emit('keypress', '', { name: 'space' });
395+
input.emit('keypress', '', { name: 'return' });
396+
397+
const value = await result;
398+
399+
expect(value).toEqual(['opt0']);
400+
expect(output.buffer).toMatchSnapshot();
401+
});
339402
});

0 commit comments

Comments
 (0)