Skip to content

Commit b103ad3

Browse files
chouchoujichouchouji43081j
authored
feat: support disabled for select and multiselect prompt (#393)
Co-authored-by: chouchouji <jibinbin@dcloud.io> Co-authored-by: James Garbutt <43081j@users.noreply.github.com>
1 parent 8409f2c commit b103ad3

File tree

13 files changed

+357
-25
lines changed

13 files changed

+357
-25
lines changed

.changeset/itchy-coins-cry.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@clack/prompts": patch
3+
"@clack/core": patch
4+
---
5+
6+
Allow disabled options in multi-select and select prompts.

packages/core/src/prompts/multi-select.ts

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,42 @@
1+
import { findCursor } from '../utils/cursor.js';
12
import Prompt, { type PromptOptions } from './prompt.js';
23

3-
interface MultiSelectOptions<T extends { value: any }>
4+
interface OptionLike {
5+
value: any;
6+
disabled?: boolean;
7+
}
8+
9+
interface MultiSelectOptions<T extends OptionLike>
410
extends PromptOptions<T['value'][], MultiSelectPrompt<T>> {
511
options: T[];
612
initialValues?: T['value'][];
713
required?: boolean;
814
cursorAt?: T['value'];
915
}
10-
export default class MultiSelectPrompt<T extends { value: any }> extends Prompt<T['value'][]> {
16+
export default class MultiSelectPrompt<T extends OptionLike> extends Prompt<T['value'][]> {
1117
options: T[];
1218
cursor = 0;
1319

1420
private get _value(): T['value'] {
1521
return this.options[this.cursor].value;
1622
}
1723

24+
private get _enabledOptions(): T[] {
25+
return this.options.filter((option) => option.disabled !== true);
26+
}
27+
1828
private toggleAll() {
19-
const allSelected = this.value !== undefined && this.value.length === this.options.length;
20-
this.value = allSelected ? [] : this.options.map((v) => v.value);
29+
const enabledOptions = this._enabledOptions;
30+
const allSelected = this.value !== undefined && this.value.length === enabledOptions.length;
31+
this.value = allSelected ? [] : enabledOptions.map((v) => v.value);
2132
}
2233

2334
private toggleInvert() {
2435
const value = this.value;
2536
if (!value) {
2637
return;
2738
}
28-
const notSelected = this.options.filter((v) => !value.includes(v.value));
39+
const notSelected = this._enabledOptions.filter((v) => !value.includes(v.value));
2940
this.value = notSelected.map((v) => v.value);
3041
}
3142

@@ -44,10 +55,11 @@ export default class MultiSelectPrompt<T extends { value: any }> extends Prompt<
4455

4556
this.options = opts.options;
4657
this.value = [...(opts.initialValues ?? [])];
47-
this.cursor = Math.max(
58+
const cursor = Math.max(
4859
this.options.findIndex(({ value }) => value === opts.cursorAt),
4960
0
5061
);
62+
this.cursor = this.options[cursor].disabled ? findCursor<T>(cursor, 1, this.options) : cursor;
5163
this.on('key', (char) => {
5264
if (char === 'a') {
5365
this.toggleAll();
@@ -61,11 +73,11 @@ export default class MultiSelectPrompt<T extends { value: any }> extends Prompt<
6173
switch (key) {
6274
case 'left':
6375
case 'up':
64-
this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1;
76+
this.cursor = findCursor<T>(this.cursor, -1, this.options);
6577
break;
6678
case 'down':
6779
case 'right':
68-
this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1;
80+
this.cursor = findCursor<T>(this.cursor, 1, this.options);
6981
break;
7082
case 'space':
7183
this.toggleValue();

packages/core/src/prompts/select.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
1+
import { findCursor } from '../utils/cursor.js';
12
import Prompt, { type PromptOptions } from './prompt.js';
23

3-
interface SelectOptions<T extends { value: any }>
4+
interface SelectOptions<T extends { value: any; disabled?: boolean }>
45
extends PromptOptions<T['value'], SelectPrompt<T>> {
56
options: T[];
67
initialValue?: T['value'];
78
}
8-
export default class SelectPrompt<T extends { value: any }> extends Prompt<T['value']> {
9+
export default class SelectPrompt<T extends { value: any; disabled?: boolean }> extends Prompt<
10+
T['value']
11+
> {
912
options: T[];
1013
cursor = 0;
1114

@@ -21,19 +24,21 @@ export default class SelectPrompt<T extends { value: any }> extends Prompt<T['va
2124
super(opts, false);
2225

2326
this.options = opts.options;
24-
this.cursor = this.options.findIndex(({ value }) => value === opts.initialValue);
25-
if (this.cursor === -1) this.cursor = 0;
27+
28+
const initialCursor = this.options.findIndex(({ value }) => value === opts.initialValue);
29+
const cursor = initialCursor === -1 ? 0 : initialCursor;
30+
this.cursor = this.options[cursor].disabled ? findCursor<T>(cursor, 1, this.options) : cursor;
2631
this.changeValue();
2732

2833
this.on('cursor', (key) => {
2934
switch (key) {
3035
case 'left':
3136
case 'up':
32-
this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1;
37+
this.cursor = findCursor<T>(this.cursor, -1, this.options);
3338
break;
3439
case 'down':
3540
case 'right':
36-
this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1;
41+
this.cursor = findCursor<T>(this.cursor, 1, this.options);
3742
break;
3843
}
3944
this.changeValue();

packages/core/src/utils/cursor.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export function findCursor<T extends { disabled?: boolean }>(
2+
cursor: number,
3+
delta: number,
4+
options: T[]
5+
) {
6+
const newCursor = cursor + delta;
7+
const maxCursor = Math.max(options.length - 1, 0);
8+
const clampedCursor = newCursor < 0 ? maxCursor : newCursor > maxCursor ? 0 : newCursor;
9+
const newOption = options[clampedCursor];
10+
if (newOption.disabled) {
11+
return findCursor(clampedCursor, delta < 0 ? -1 : 1, options);
12+
}
13+
return clampedCursor;
14+
}

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

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,5 +130,84 @@ describe('MultiSelectPrompt', () => {
130130
input.emit('keypress', 'i', { name: 'i' });
131131
expect(instance.value).toEqual(['foo']);
132132
});
133+
134+
test('disabled options are skipped', () => {
135+
const instance = new MultiSelectPrompt({
136+
input,
137+
output,
138+
render: () => 'foo',
139+
options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }],
140+
});
141+
instance.prompt();
142+
143+
expect(instance.cursor).to.equal(0);
144+
input.emit('keypress', 'down', { name: 'down' });
145+
expect(instance.cursor).to.equal(2);
146+
input.emit('keypress', 'up', { name: 'up' });
147+
expect(instance.cursor).to.equal(0);
148+
});
149+
150+
test('initial cursorAt on disabled option', () => {
151+
const instance = new MultiSelectPrompt({
152+
input,
153+
output,
154+
render: () => 'foo',
155+
options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }],
156+
cursorAt: 'bar',
157+
});
158+
instance.prompt();
159+
160+
expect(instance.cursor).to.equal(2);
161+
});
162+
});
163+
164+
describe('toggleAll', () => {
165+
test('selects all enabled options', () => {
166+
const instance = new MultiSelectPrompt({
167+
input,
168+
output,
169+
render: () => 'foo',
170+
options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }],
171+
});
172+
instance.prompt();
173+
174+
input.emit('keypress', 'a', { name: 'a' });
175+
expect(instance.value).toEqual(['foo', 'baz']);
176+
});
177+
178+
test('unselects all enabled options if all selected', () => {
179+
const instance = new MultiSelectPrompt({
180+
input,
181+
output,
182+
render: () => 'foo',
183+
options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }],
184+
initialValues: ['foo', 'baz'],
185+
});
186+
instance.prompt();
187+
188+
input.emit('keypress', 'a', { name: 'a' });
189+
expect(instance.value).toEqual([]);
190+
});
191+
});
192+
193+
describe('toggleInvert', () => {
194+
test('inverts selection of enabled options', () => {
195+
const instance = new MultiSelectPrompt({
196+
input,
197+
output,
198+
render: () => 'foo',
199+
options: [
200+
{ value: 'foo' },
201+
{ value: 'bar', disabled: true },
202+
{ value: 'baz' },
203+
{ value: 'qux' },
204+
],
205+
initialValues: ['foo', 'baz'],
206+
});
207+
instance.prompt();
208+
209+
input.emit('keypress', 'i', { name: 'i' });
210+
expect(instance.value).toEqual(['qux']);
211+
});
133212
});
134213
});

packages/core/test/prompts/select.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,5 +100,43 @@ describe('SelectPrompt', () => {
100100
instance.prompt();
101101
expect(instance.cursor).to.equal(1);
102102
});
103+
104+
test('cursor skips disabled options (down)', () => {
105+
const instance = new SelectPrompt({
106+
input,
107+
output,
108+
render: () => 'foo',
109+
options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }],
110+
});
111+
instance.prompt();
112+
expect(instance.cursor).to.equal(0);
113+
input.emit('keypress', 'down', { name: 'down' });
114+
expect(instance.cursor).to.equal(2);
115+
});
116+
117+
test('cursor skips disabled options (up)', () => {
118+
const instance = new SelectPrompt({
119+
input,
120+
output,
121+
render: () => 'foo',
122+
initialValue: 'baz',
123+
options: [{ value: 'foo' }, { value: 'bar', disabled: true }, { value: 'baz' }],
124+
});
125+
instance.prompt();
126+
expect(instance.cursor).to.equal(2);
127+
input.emit('keypress', 'up', { name: 'up' });
128+
expect(instance.cursor).to.equal(0);
129+
});
130+
131+
test('cursor skips initial disabled option', () => {
132+
const instance = new SelectPrompt({
133+
input,
134+
output,
135+
render: () => 'foo',
136+
options: [{ value: 'foo', disabled: true }, { value: 'bar' }, { value: 'baz' }],
137+
});
138+
instance.prompt();
139+
expect(instance.cursor).to.equal(1);
140+
});
103141
});
104142
});

packages/prompts/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ const projectType = await select({
8686
message: 'Pick a project type.',
8787
options: [
8888
{ value: 'ts', label: 'TypeScript' },
89-
{ value: 'js', label: 'JavaScript' },
89+
{ value: 'js', label: 'JavaScript', disabled: true },
9090
{ value: 'coffee', label: 'CoffeeScript', hint: 'oh no' },
9191
],
9292
});
@@ -103,7 +103,7 @@ const additionalTools = await multiselect({
103103
message: 'Select additional tools.',
104104
options: [
105105
{ value: 'eslint', label: 'ESLint', hint: 'recommended' },
106-
{ value: 'prettier', label: 'Prettier' },
106+
{ value: 'prettier', label: 'Prettier', disabled: true },
107107
{ value: 'gh-action', label: 'GitHub Action' },
108108
],
109109
required: false,

packages/prompts/src/multi-select.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,21 @@ export interface MultiSelectOptions<Value> extends CommonOptions {
2323
export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
2424
const opt = (
2525
option: Option<Value>,
26-
state: 'inactive' | 'active' | 'selected' | 'active-selected' | 'submitted' | 'cancelled'
26+
state:
27+
| 'inactive'
28+
| 'active'
29+
| 'selected'
30+
| 'active-selected'
31+
| 'submitted'
32+
| 'cancelled'
33+
| 'disabled'
2734
) => {
2835
const label = option.label ?? String(option.value);
36+
if (state === 'disabled') {
37+
return `${color.gray(S_CHECKBOX_INACTIVE)} ${color.gray(label)}${
38+
option.hint ? ` ${color.dim(`(${option.hint ?? 'disabled'})`)}` : ''
39+
}`;
40+
}
2941
if (state === 'active') {
3042
return `${color.cyan(S_CHECKBOX_ACTIVE)} ${label}${
3143
option.hint ? ` ${color.dim(`(${option.hint})`)}` : ''
@@ -74,6 +86,9 @@ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
7486
const value = this.value ?? [];
7587

7688
const styleOption = (option: Option<Value>, active: boolean) => {
89+
if (option.disabled) {
90+
return opt(option, 'disabled');
91+
}
7792
const selected = value.includes(option.value);
7893
if (active && selected) {
7994
return opt(option, 'active-selected');
@@ -103,28 +118,32 @@ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
103118
}`;
104119
}
105120
case 'error': {
121+
const prefix = `${color.yellow(S_BAR)} `;
106122
const footer = this.error
107123
.split('\n')
108124
.map((ln, i) =>
109125
i === 0 ? `${color.yellow(S_BAR_END)} ${color.yellow(ln)}` : ` ${ln}`
110126
)
111127
.join('\n');
112-
return `${title + color.yellow(S_BAR)} ${limitOptions({
128+
return `${title}${prefix}${limitOptions({
113129
output: opts.output,
114130
options: this.options,
115131
cursor: this.cursor,
116132
maxItems: opts.maxItems,
133+
columnPadding: prefix.length,
117134
style: styleOption,
118-
}).join(`\n${color.yellow(S_BAR)} `)}\n${footer}\n`;
135+
}).join(`\n${prefix}`)}\n${footer}\n`;
119136
}
120137
default: {
121-
return `${title}${color.cyan(S_BAR)} ${limitOptions({
138+
const prefix = `${color.cyan(S_BAR)} `;
139+
return `${title}${prefix}${limitOptions({
122140
output: opts.output,
123141
options: this.options,
124142
cursor: this.cursor,
125143
maxItems: opts.maxItems,
144+
columnPadding: prefix.length,
126145
style: styleOption,
127-
}).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`;
146+
}).join(`\n${prefix}`)}\n${color.cyan(S_BAR_END)}\n`;
128147
}
129148
}
130149
},

0 commit comments

Comments
 (0)