Skip to content

Commit 818373d

Browse files
committed
Merge branch 'multiselect-max-items' of https://github.com/Mist3rBru/clack into multiselect-max-items
2 parents 9d0e0dc + b5c6b9b commit 818373d

File tree

2 files changed

+81
-69
lines changed

2 files changed

+81
-69
lines changed

.changeset/bright-rules-yell.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+
Feat multiselect maxItems option

packages/prompts/src/index.ts

Lines changed: 76 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,41 @@ const symbol = (state: State) => {
5858
}
5959
};
6060

61+
interface LimitOptionsParams<TOption> {
62+
options: TOption[];
63+
maxItems: number | undefined;
64+
cursor: number;
65+
style: (option: TOption, active: boolean) => string;
66+
}
67+
68+
const limitOptions = <TOption>(params: LimitOptionsParams<TOption>): string[] => {
69+
const { cursor, options, style } = params;
70+
71+
// We clamp to minimum 5 because anything less doesn't make sense UX wise
72+
const maxItems = params.maxItems === undefined ? Infinity : Math.max(params.maxItems, 5);
73+
let slidingWindowLocation = 0;
74+
75+
if (cursor >= slidingWindowLocation + maxItems - 3) {
76+
slidingWindowLocation = Math.max(Math.min(cursor - maxItems + 3, options.length - maxItems), 0);
77+
} else if (cursor < slidingWindowLocation + 2) {
78+
slidingWindowLocation = Math.max(cursor - 2, 0);
79+
}
80+
81+
const shouldRenderTopEllipsis = maxItems < options.length && slidingWindowLocation > 0;
82+
const shouldRenderBottomEllipsis =
83+
maxItems < options.length && slidingWindowLocation + maxItems < options.length;
84+
85+
return options
86+
.slice(slidingWindowLocation, slidingWindowLocation + maxItems)
87+
.map((option, i, arr) => {
88+
const isTopLimit = i === 0 && shouldRenderTopEllipsis;
89+
const isBottomLimit = i === arr.length - 1 && shouldRenderBottomEllipsis;
90+
return isTopLimit || isBottomLimit
91+
? color.dim('...')
92+
: style(option, i + slidingWindowLocation === cursor);
93+
});
94+
};
95+
6196
export interface TextOptions {
6297
message: string;
6398
placeholder?: string;
@@ -184,20 +219,20 @@ export interface SelectOptions<Value> {
184219
export const select = <Value>(opts: SelectOptions<Value>) => {
185220
const opt = (option: Option<Value>, state: 'inactive' | 'active' | 'selected' | 'cancelled') => {
186221
const label = option.label ?? String(option.value);
187-
if (state === 'active') {
188-
return `${color.green(S_RADIO_ACTIVE)} ${label} ${
189-
option.hint ? color.dim(`(${option.hint})`) : ''
190-
}`;
191-
} else if (state === 'selected') {
192-
return `${color.dim(label)}`;
193-
} else if (state === 'cancelled') {
194-
return `${color.strikethrough(color.dim(label))}`;
222+
switch (state) {
223+
case 'selected':
224+
return `${color.dim(label)}`;
225+
case 'active':
226+
return `${color.green(S_RADIO_ACTIVE)} ${label} ${
227+
option.hint ? color.dim(`(${option.hint})`) : ''
228+
}`;
229+
case 'cancelled':
230+
return `${color.strikethrough(color.dim(label))}`;
231+
default:
232+
return `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}`;
195233
}
196-
return `${color.dim(S_RADIO_INACTIVE)} ${color.dim(label)}`;
197234
};
198235

199-
let slidingWindowLocation = 0;
200-
201236
return new SelectPrompt({
202237
options: opts.options,
203238
initialValue: opts.initialValue,
@@ -213,38 +248,12 @@ export const select = <Value>(opts: SelectOptions<Value>) => {
213248
'cancelled'
214249
)}\n${color.gray(S_BAR)}`;
215250
default: {
216-
// We clamp to minimum 5 because anything less doesn't make sense UX wise
217-
const maxItems = opts.maxItems === undefined ? Infinity : Math.max(opts.maxItems, 5);
218-
if (this.cursor >= slidingWindowLocation + maxItems - 3) {
219-
slidingWindowLocation = Math.max(
220-
Math.min(this.cursor - maxItems + 3, this.options.length - maxItems),
221-
0
222-
);
223-
} else if (this.cursor < slidingWindowLocation + 2) {
224-
slidingWindowLocation = Math.max(this.cursor - 2, 0);
225-
}
226-
227-
const shouldRenderTopEllipsis =
228-
maxItems < this.options.length && slidingWindowLocation > 0;
229-
const shouldRenderBottomEllipsis =
230-
maxItems < this.options.length &&
231-
slidingWindowLocation + maxItems < this.options.length;
232-
233-
return `${title}${color.cyan(S_BAR)} ${this.options
234-
.slice(slidingWindowLocation, slidingWindowLocation + maxItems)
235-
.map((option, i, arr) => {
236-
if (i === 0 && shouldRenderTopEllipsis) {
237-
return color.dim('...');
238-
} else if (i === arr.length - 1 && shouldRenderBottomEllipsis) {
239-
return color.dim('...');
240-
} else {
241-
return opt(
242-
option,
243-
i + slidingWindowLocation === this.cursor ? 'active' : 'inactive'
244-
);
245-
}
246-
})
247-
.join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`;
251+
return `${title}${color.cyan(S_BAR)} ${limitOptions({
252+
cursor: this.cursor,
253+
options: this.options,
254+
maxItems: opts.maxItems,
255+
style: (item, active) => opt(item, active ? 'active' : 'inactive'),
256+
}).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`;
248257
}
249258
}
250259
},
@@ -301,6 +310,7 @@ export interface MultiSelectOptions<Value> {
301310
message: string;
302311
options: Option<Value>[];
303312
initialValues?: Value[];
313+
maxItems?: number;
304314
required?: boolean;
305315
cursorAt?: Value;
306316
}
@@ -346,6 +356,17 @@ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
346356
render() {
347357
let title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`;
348358

359+
const styleOption = (option: Option<Value>, active: boolean) => {
360+
const selected = this.value.includes(option.value);
361+
if (active && selected) {
362+
return opt(option, 'active-selected');
363+
}
364+
if (selected) {
365+
return opt(option, 'selected');
366+
}
367+
return opt(option, active ? 'active' : 'inactive');
368+
};
369+
349370
switch (this.state) {
350371
case 'submit': {
351372
return `${title}${color.gray(S_BAR)} ${
@@ -375,38 +396,24 @@ export const multiselect = <Value>(opts: MultiSelectOptions<Value>) => {
375396
title +
376397
color.yellow(S_BAR) +
377398
' ' +
378-
this.options
379-
.map((option, i) => {
380-
const selected = this.value.includes(option.value);
381-
const active = i === this.cursor;
382-
if (active && selected) {
383-
return opt(option, 'active-selected');
384-
}
385-
if (selected) {
386-
return opt(option, 'selected');
387-
}
388-
return opt(option, active ? 'active' : 'inactive');
389-
})
390-
.join(`\n${color.yellow(S_BAR)} `) +
399+
limitOptions({
400+
options: this.options,
401+
cursor: this.cursor,
402+
maxItems: opts.maxItems,
403+
style: styleOption,
404+
}).join(`\n${color.yellow(S_BAR)} `) +
391405
'\n' +
392406
footer +
393407
'\n'
394408
);
395409
}
396410
default: {
397-
return `${title}${color.cyan(S_BAR)} ${this.options
398-
.map((option, i) => {
399-
const selected = this.value.includes(option.value);
400-
const active = i === this.cursor;
401-
if (active && selected) {
402-
return opt(option, 'active-selected');
403-
}
404-
if (selected) {
405-
return opt(option, 'selected');
406-
}
407-
return opt(option, active ? 'active' : 'inactive');
408-
})
409-
.join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`;
411+
return `${title}${color.cyan(S_BAR)} ${limitOptions({
412+
options: this.options,
413+
cursor: this.cursor,
414+
maxItems: opts.maxItems,
415+
style: styleOption,
416+
}).join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`;
410417
}
411418
}
412419
},

0 commit comments

Comments
 (0)