diff --git a/.changeset/free-wasps-decide.md b/.changeset/free-wasps-decide.md new file mode 100644 index 00000000..d133735e --- /dev/null +++ b/.changeset/free-wasps-decide.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": patch +"@clack/core": patch +--- + +Adds a new `selectableGroups` boolean to the group multi-select prompt. Using `selectableGroups: false` will disable the ability to select a top-level group, but still allow every child to be selected individually. diff --git a/packages/core/src/prompts/group-multiselect.ts b/packages/core/src/prompts/group-multiselect.ts index 3da6c331..c052765d 100644 --- a/packages/core/src/prompts/group-multiselect.ts +++ b/packages/core/src/prompts/group-multiselect.ts @@ -6,10 +6,12 @@ interface GroupMultiSelectOptions initialValues?: T['value'][]; required?: boolean; cursorAt?: T['value']; + selectableGroups?: boolean; } export default class GroupMultiSelectPrompt extends Prompt { options: (T & { group: string | boolean })[]; cursor = 0; + #selectableGroups: boolean; getGroupItems(group: string): T[] { return this.options.filter((o) => o.group === group); @@ -44,6 +46,7 @@ export default class GroupMultiSelectPrompt extends Pr constructor(opts: GroupMultiSelectOptions) { super(opts, false); const { options } = opts; + this.#selectableGroups = opts.selectableGroups !== false; this.options = Object.entries(options).flatMap(([key, option]) => [ { value: key, group: true, label: key }, ...option.map((opt) => ({ ...opt, group: key })), @@ -51,19 +54,29 @@ export default class GroupMultiSelectPrompt extends Pr this.value = [...(opts.initialValues ?? [])]; this.cursor = Math.max( this.options.findIndex(({ value }) => value === opts.cursorAt), - 0 + this.#selectableGroups ? 0 : 1 ); this.on('cursor', (key) => { switch (key) { case 'left': - case 'up': + case 'up': { this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; + const currentIsGroup = this.options[this.cursor]?.group === true; + if (!this.#selectableGroups && currentIsGroup) { + this.cursor = this.cursor === 0 ? this.options.length - 1 : this.cursor - 1; + } break; + } case 'down': - case 'right': + case 'right': { this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; + const currentIsGroup = this.options[this.cursor]?.group === true; + if (!this.#selectableGroups && currentIsGroup) { + this.cursor = this.cursor === this.options.length - 1 ? 0 : this.cursor + 1; + } break; + } case 'space': this.toggleValue(); break; diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index e50ecf3a..01cd736b 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -462,8 +462,10 @@ export interface GroupMultiSelectOptions { initialValues?: Value[]; required?: boolean; cursorAt?: Value; + selectableGroups?: boolean; } export const groupMultiselect = (opts: GroupMultiSelectOptions) => { + const { selectableGroups = true } = opts; const opt = ( option: Option, state: @@ -481,7 +483,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => const isItem = typeof (option as any).group === 'string'; const next = isItem && (options[options.indexOf(option) + 1] ?? { group: true }); const isLast = isItem && (next as any).group === true; - const prefix = isItem ? `${isLast ? S_BAR_END : S_BAR} ` : ''; + const prefix = isItem ? (selectableGroups ? `${isLast ? S_BAR_END : S_BAR} ` : ' ') : ''; if (state === 'active') { return `${color.dim(prefix)}${color.cyan(S_CHECKBOX_ACTIVE)} ${label} ${ @@ -495,7 +497,8 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => return `${prefix}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; } if (state === 'selected') { - return `${color.dim(prefix)}${color.green(S_CHECKBOX_SELECTED)} ${color.dim(label)}`; + const selectedCheckbox = isItem || selectableGroups ? color.green(S_CHECKBOX_SELECTED) : ''; + return `${color.dim(prefix)}${selectedCheckbox} ${color.dim(label)}`; } if (state === 'cancelled') { return `${color.strikethrough(color.dim(label))}`; @@ -508,7 +511,8 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => if (state === 'submitted') { return `${color.dim(label)}`; } - return `${color.dim(prefix)}${color.dim(S_CHECKBOX_INACTIVE)} ${color.dim(label)}`; + const unselectedCheckbox = isItem || selectableGroups ? color.dim(S_CHECKBOX_INACTIVE) : ''; + return `${color.dim(prefix)}${unselectedCheckbox} ${color.dim(label)}`; }; return new GroupMultiSelectPrompt({ @@ -516,6 +520,7 @@ export const groupMultiselect = (opts: GroupMultiSelectOptions) => initialValues: opts.initialValues, required: opts.required ?? true, cursorAt: opts.cursorAt, + selectableGroups, validate(selected: Value[]) { if (this.required && selected.length === 0) return `Please select at least one option.\n${color.reset(