From 714511ec8d494183ebc11ecb347b9d5435237210 Mon Sep 17 00:00:00 2001 From: James Garbutt <43081j@users.noreply.github.com> Date: Wed, 14 Jan 2026 16:59:18 +0000 Subject: [PATCH] feat: add `caseSensitive` option to select-key Adds a new `caseSensitive` option such that you can bind to uppercase keys. Also fixes a bunch of line-wrapping render bugs and adds a test suite. Fixes #435. --- .changeset/three-boxes-follow.md | 6 + packages/core/src/prompts/select-key.ts | 21 +- packages/prompts/src/select-key.ts | 67 ++- .../__snapshots__/select-key.test.ts.snap | 541 ++++++++++++++++++ packages/prompts/test/select-key.test.ts | 236 ++++++++ 5 files changed, 847 insertions(+), 24 deletions(-) create mode 100644 .changeset/three-boxes-follow.md create mode 100644 packages/prompts/test/__snapshots__/select-key.test.ts.snap create mode 100644 packages/prompts/test/select-key.test.ts diff --git a/.changeset/three-boxes-follow.md b/.changeset/three-boxes-follow.md new file mode 100644 index 00000000..13c02b76 --- /dev/null +++ b/.changeset/three-boxes-follow.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": patch +"@clack/core": patch +--- + +select-key: Fixed wrapping and added new `caseSensitive` option diff --git a/packages/core/src/prompts/select-key.ts b/packages/core/src/prompts/select-key.ts index 2fd225a2..2e8e0b10 100644 --- a/packages/core/src/prompts/select-key.ts +++ b/packages/core/src/prompts/select-key.ts @@ -3,6 +3,7 @@ import Prompt, { type PromptOptions } from './prompt.js'; export interface SelectKeyOptions extends PromptOptions> { options: T[]; + caseSensitive?: boolean; } export default class SelectKeyPrompt extends Prompt { options: T[]; @@ -12,12 +13,24 @@ export default class SelectKeyPrompt extends Prompt super(opts, false); this.options = opts.options; - const keys = this.options.map(({ value: [initial] }) => initial?.toLowerCase()); + const caseSensitive = opts.caseSensitive === true; + const keys = this.options.map(({ value: [initial] }) => { + return caseSensitive ? initial : initial?.toLowerCase(); + }); this.cursor = Math.max(keys.indexOf(opts.initialValue), 0); - this.on('key', (key) => { - if (!key || !keys.includes(key)) return; - const value = this.options.find(({ value: [initial] }) => initial?.toLowerCase() === key); + this.on('key', (key, keyInfo) => { + if (!key) { + return; + } + const casedKey = caseSensitive && keyInfo.shift ? key.toUpperCase() : key; + if (!keys.includes(casedKey)) { + return; + } + + const value = this.options.find(({ value: [initial] }) => { + return caseSensitive ? initial === casedKey : initial?.toLowerCase() === key; + }); if (value) { this.value = value.value; this.state = 'submit'; diff --git a/packages/prompts/src/select-key.ts b/packages/prompts/src/select-key.ts index f5bbbf69..832c44d0 100644 --- a/packages/prompts/src/select-key.ts +++ b/packages/prompts/src/select-key.ts @@ -1,9 +1,16 @@ -import { SelectKeyPrompt } from '@clack/core'; +import { SelectKeyPrompt, wrapTextWithPrefix } from '@clack/core'; import color from 'picocolors'; -import { S_BAR, S_BAR_END, symbol } from './common.js'; -import type { Option, SelectOptions } from './select.js'; +import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; +import type { Option } from './select.js'; -export const selectKey = (opts: SelectOptions) => { +export interface SelectKeyOptions extends CommonOptions { + message: string; + options: Option[]; + initialValue?: Value; + caseSensitive?: boolean; +} + +export const selectKey = (opts: SelectKeyOptions) => { const opt = ( option: Option, state: 'inactive' | 'active' | 'selected' | 'cancelled' = 'inactive' @@ -16,12 +23,12 @@ export const selectKey = (opts: SelectOptions) => { return `${color.strikethrough(color.dim(label))}`; } if (state === 'active') { - return `${color.bgCyan(color.gray(` ${option.value} `))} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : '' + return `${color.bgCyan(color.gray(` ${option.value} `))} ${label}${ + option.hint ? ` ${color.dim(`(${option.hint})`)}` : '' }`; } - return `${color.gray(color.bgWhite(color.inverse(` ${option.value} `)))} ${label} ${ - option.hint ? color.dim(`(${option.hint})`) : '' + return `${color.gray(color.bgWhite(color.inverse(` ${option.value} `)))} ${label}${ + option.hint ? ` ${color.dim(`(${option.hint})`)}` : '' }`; }; @@ -31,23 +38,43 @@ export const selectKey = (opts: SelectOptions) => { input: opts.input, output: opts.output, initialValue: opts.initialValue, + caseSensitive: opts.caseSensitive, render() { const title = `${color.gray(S_BAR)}\n${symbol(this.state)} ${opts.message}\n`; switch (this.state) { - case 'submit': - return `${title}${color.gray(S_BAR)} ${opt( - this.options.find((opt) => opt.value === this.value) ?? opts.options[0], - 'selected' - )}`; - case 'cancel': - return `${title}${color.gray(S_BAR)} ${opt(this.options[0], 'cancelled')}\n${color.gray( - S_BAR - )}`; + case 'submit': { + const submitPrefix = `${color.gray(S_BAR)} `; + const selectedOption = + this.options.find((opt) => opt.value === this.value) ?? opts.options[0]; + const wrapped = wrapTextWithPrefix( + opts.output, + opt(selectedOption, 'selected'), + submitPrefix + ); + return `${title}${wrapped}`; + } + case 'cancel': { + const cancelPrefix = `${color.gray(S_BAR)} `; + const wrapped = wrapTextWithPrefix( + opts.output, + opt(this.options[0], 'cancelled'), + cancelPrefix + ); + return `${title}${wrapped}\n${color.gray(S_BAR)}`; + } default: { - return `${title}${color.cyan(S_BAR)} ${this.options - .map((option, i) => opt(option, i === this.cursor ? 'active' : 'inactive')) - .join(`\n${color.cyan(S_BAR)} `)}\n${color.cyan(S_BAR_END)}\n`; + const defaultPrefix = `${color.cyan(S_BAR)} `; + const wrapped = this.options + .map((option, i) => + wrapTextWithPrefix( + opts.output, + opt(option, i === this.cursor ? 'active' : 'inactive'), + defaultPrefix + ) + ) + .join('\n'); + return `${title}${wrapped}\n${color.cyan(S_BAR_END)}\n`; } } }, diff --git a/packages/prompts/test/__snapshots__/select-key.test.ts.snap b/packages/prompts/test/__snapshots__/select-key.test.ts.snap new file mode 100644 index 00000000..c08c98b8 --- /dev/null +++ b/packages/prompts/test/__snapshots__/select-key.test.ts.snap @@ -0,0 +1,541 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`text (isCI = false) > can cancel by pressing escape 1`] = ` +[ + "", + "│ +◆ foo +│  a  Option A +│  b  Option B +└ +", + "", + "", + "", + "■ foo +│ Option A +│", + " +", + "", +] +`; + +exports[`text (isCI = false) > caseSensitive: true makes input case-sensitive 1`] = ` +[ + "", + "│ +◆ foo +│  a  Option a +│  A  Option A +│  b  Option B +└ +", + "", + "", + "", + "", + "◇ foo +│ Option A", + " +", +] +`; + +exports[`text (isCI = false) > caseSensitive: true makes options case-sensitive 1`] = ` +[ + "", + "│ +◆ foo +│  A  Option A +│  b  Option B +└ +", + "", + "", + "", + "■ foo +│ Option A +│", + " +", + "", +] +`; + +exports[`text (isCI = false) > input is case-insensitive by default 1`] = ` +[ + "", + "│ +◆ foo +│  a  Option A +│  b  Option B +└ +", + "", + "", + "", + "", + "◇ foo +│ Option A", + " +", +] +`; + +exports[`text (isCI = false) > long cancelled labels are wrapped correctly 1`] = ` +[ + "", + "│ +◆ Select an option: +│  a  This is a somewhat long +│ label This is a somewhat +│ long label This is a +│ somewhat long label This is +│ a somewhat long label This +│ is a somewhat long label +│ This is a somewhat long +│ label This is a somewhat +│ long label This is a +│ somewhat long label This is +│ a somewhat long label This +│ is a somewhat long label +│  b  Short label +└ +", + "", + "", + "", + "■ Select an option: +│ This is a somewhat long  +│ label This is a somewhat  +│ long label This is a  +│ somewhat long label This is +│  a somewhat long label This +│  is a somewhat long label  +│ This is a somewhat long  +│ label This is a somewhat  +│ long label This is a  +│ somewhat long label This is +│  a somewhat long label This +│  is a somewhat long label +│", + " +", + "", +] +`; + +exports[`text (isCI = false) > long option labels are wrapped correctly 1`] = ` +[ + "", + "│ +◆ Select an option: +│  a  This is a somewhat long +│ label This is a somewhat +│ long label This is a +│ somewhat long label This is +│ a somewhat long label This +│ is a somewhat long label +│ This is a somewhat long +│ label This is a somewhat +│ long label This is a +│ somewhat long label This is +│ a somewhat long label This +│ is a somewhat long label +│  b  Short label +└ +", + "", + "", + "", + "", + "◇ Select an option: +│ This is a somewhat long  +│ label This is a somewhat  +│ long label This is a  +│ somewhat long label This is +│  a somewhat long label This +│  is a somewhat long label  +│ This is a somewhat long  +│ label This is a somewhat  +│ long label This is a  +│ somewhat long label This is +│  a somewhat long label This +│  is a somewhat long label", + " +", +] +`; + +exports[`text (isCI = false) > long submitted labels are wrapped correctly 1`] = ` +[ + "", + "│ +◆ Select an option: +│  a  This is a somewhat long +│ label This is a somewhat +│ long label This is a +│ somewhat long label This is +│ a somewhat long label This +│ is a somewhat long label +│ This is a somewhat long +│ label This is a somewhat +│ long label This is a +│ somewhat long label This is +│ a somewhat long label This +│ is a somewhat long label +│  b  Short label +└ +", + "", + "", + "", + "", + "◇ Select an option: +│ This is a somewhat long  +│ label This is a somewhat  +│ long label This is a  +│ somewhat long label This is +│  a somewhat long label This +│  is a somewhat long label  +│ This is a somewhat long  +│ label This is a somewhat  +│ long label This is a  +│ somewhat long label This is +│  a somewhat long label This +│  is a somewhat long label", + " +", +] +`; + +exports[`text (isCI = false) > options are case-insensitive by default 1`] = ` +[ + "", + "│ +◆ foo +│  A  Option A +│  b  Option B +└ +", + "", + "", + "", + "", + "◇ foo +│ Option A", + " +", +] +`; + +exports[`text (isCI = false) > renders message with options 1`] = ` +[ + "", + "│ +◆ foo +│  a  Option A +│  b  Option B +└ +", + "", + "", + "", + "◇ foo +│ Option A", + " +", + "", +] +`; + +exports[`text (isCI = false) > selects option by keypress 1`] = ` +[ + "", + "│ +◆ foo +│  a  Option A +│  b  Option B +└ +", + "", + "", + "", + "", + "◇ foo +│ Option B", + " +", +] +`; + +exports[`text (isCI = true) > can cancel by pressing escape 1`] = ` +[ + "", + "│ +◆ foo +│  a  Option A +│  b  Option B +└ +", + "", + "", + "", + "■ foo +│ Option A +│", + " +", + "", +] +`; + +exports[`text (isCI = true) > caseSensitive: true makes input case-sensitive 1`] = ` +[ + "", + "│ +◆ foo +│  a  Option a +│  A  Option A +│  b  Option B +└ +", + "", + "", + "", + "", + "◇ foo +│ Option A", + " +", +] +`; + +exports[`text (isCI = true) > caseSensitive: true makes options case-sensitive 1`] = ` +[ + "", + "│ +◆ foo +│  A  Option A +│  b  Option B +└ +", + "", + "", + "", + "■ foo +│ Option A +│", + " +", + "", +] +`; + +exports[`text (isCI = true) > input is case-insensitive by default 1`] = ` +[ + "", + "│ +◆ foo +│  a  Option A +│  b  Option B +└ +", + "", + "", + "", + "", + "◇ foo +│ Option A", + " +", +] +`; + +exports[`text (isCI = true) > long cancelled labels are wrapped correctly 1`] = ` +[ + "", + "│ +◆ Select an option: +│  a  This is a somewhat long +│ label This is a somewhat +│ long label This is a +│ somewhat long label This is +│ a somewhat long label This +│ is a somewhat long label +│ This is a somewhat long +│ label This is a somewhat +│ long label This is a +│ somewhat long label This is +│ a somewhat long label This +│ is a somewhat long label +│  b  Short label +└ +", + "", + "", + "", + "■ Select an option: +│ This is a somewhat long  +│ label This is a somewhat  +│ long label This is a  +│ somewhat long label This is +│  a somewhat long label This +│  is a somewhat long label  +│ This is a somewhat long  +│ label This is a somewhat  +│ long label This is a  +│ somewhat long label This is +│  a somewhat long label This +│  is a somewhat long label +│", + " +", + "", +] +`; + +exports[`text (isCI = true) > long option labels are wrapped correctly 1`] = ` +[ + "", + "│ +◆ Select an option: +│  a  This is a somewhat long +│ label This is a somewhat +│ long label This is a +│ somewhat long label This is +│ a somewhat long label This +│ is a somewhat long label +│ This is a somewhat long +│ label This is a somewhat +│ long label This is a +│ somewhat long label This is +│ a somewhat long label This +│ is a somewhat long label +│  b  Short label +└ +", + "", + "", + "", + "", + "◇ Select an option: +│ This is a somewhat long  +│ label This is a somewhat  +│ long label This is a  +│ somewhat long label This is +│  a somewhat long label This +│  is a somewhat long label  +│ This is a somewhat long  +│ label This is a somewhat  +│ long label This is a  +│ somewhat long label This is +│  a somewhat long label This +│  is a somewhat long label", + " +", +] +`; + +exports[`text (isCI = true) > long submitted labels are wrapped correctly 1`] = ` +[ + "", + "│ +◆ Select an option: +│  a  This is a somewhat long +│ label This is a somewhat +│ long label This is a +│ somewhat long label This is +│ a somewhat long label This +│ is a somewhat long label +│ This is a somewhat long +│ label This is a somewhat +│ long label This is a +│ somewhat long label This is +│ a somewhat long label This +│ is a somewhat long label +│  b  Short label +└ +", + "", + "", + "", + "", + "◇ Select an option: +│ This is a somewhat long  +│ label This is a somewhat  +│ long label This is a  +│ somewhat long label This is +│  a somewhat long label This +│  is a somewhat long label  +│ This is a somewhat long  +│ label This is a somewhat  +│ long label This is a  +│ somewhat long label This is +│  a somewhat long label This +│  is a somewhat long label", + " +", +] +`; + +exports[`text (isCI = true) > options are case-insensitive by default 1`] = ` +[ + "", + "│ +◆ foo +│  A  Option A +│  b  Option B +└ +", + "", + "", + "", + "", + "◇ foo +│ Option A", + " +", +] +`; + +exports[`text (isCI = true) > renders message with options 1`] = ` +[ + "", + "│ +◆ foo +│  a  Option A +│  b  Option B +└ +", + "", + "", + "", + "◇ foo +│ Option A", + " +", + "", +] +`; + +exports[`text (isCI = true) > selects option by keypress 1`] = ` +[ + "", + "│ +◆ foo +│  a  Option A +│  b  Option B +└ +", + "", + "", + "", + "", + "◇ foo +│ Option B", + " +", +] +`; diff --git a/packages/prompts/test/select-key.test.ts b/packages/prompts/test/select-key.test.ts new file mode 100644 index 00000000..d676729a --- /dev/null +++ b/packages/prompts/test/select-key.test.ts @@ -0,0 +1,236 @@ +import { updateSettings } from '@clack/core'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as prompts from '../src/index.js'; +import { MockReadable, MockWritable } from './test-utils.js'; + +describe.each(['true', 'false'])('text (isCI = %s)', (isCI) => { + let originalCI: string | undefined; + let output: MockWritable; + let input: MockReadable; + + beforeAll(() => { + originalCI = process.env.CI; + process.env.CI = isCI; + }); + + afterAll(() => { + process.env.CI = originalCI; + }); + + beforeEach(() => { + output = new MockWritable(); + input = new MockReadable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + updateSettings({ withGuide: true }); + }); + + test('renders message with options', async () => { + const result = prompts.selectKey({ + message: 'foo', + options: [ + { label: 'Option A', value: 'a' }, + { label: 'Option B', value: 'b' }, + ], + input, + output, + }); + + input.emit('keypress', '', { name: 'return' }); + + const value = await result; + + expect(output.buffer).toMatchSnapshot(); + expect(value).toBe(undefined); + }); + + test('selects option by keypress', async () => { + const result = prompts.selectKey({ + message: 'foo', + options: [ + { label: 'Option A', value: 'a' }, + { label: 'Option B', value: 'b' }, + ], + input, + output, + }); + + input.emit('keypress', 'b', { name: 'b' }); + + const value = await result; + + expect(output.buffer).toMatchSnapshot(); + expect(value).toBe('b'); + }); + + test('can cancel by pressing escape', async () => { + const result = prompts.selectKey({ + message: 'foo', + options: [ + { label: 'Option A', value: 'a' }, + { label: 'Option B', value: 'b' }, + ], + input, + output, + }); + + input.emit('keypress', 'escape', { name: 'escape' }); + + const value = await result; + + expect(output.buffer).toMatchSnapshot(); + expect(prompts.isCancel(value)).toBe(true); + }); + + test('options are case-insensitive by default', async () => { + const result = prompts.selectKey({ + message: 'foo', + options: [ + { label: 'Option A', value: 'A' }, + { label: 'Option B', value: 'b' }, + ], + input, + output, + }); + + input.emit('keypress', 'a', { name: 'a' }); + + const value = await result; + + expect(output.buffer).toMatchSnapshot(); + expect(value).toBe('A'); + }); + + test('input is case-insensitive by default', async () => { + const result = prompts.selectKey({ + message: 'foo', + options: [ + { label: 'Option A', value: 'a' }, + { label: 'Option B', value: 'b' }, + ], + input, + output, + }); + + input.emit('keypress', 'a', { name: 'a', shift: true }); + + const value = await result; + + expect(output.buffer).toMatchSnapshot(); + expect(value).toBe('a'); + }); + + test('caseSensitive: true makes options case-sensitive', async () => { + const result = prompts.selectKey({ + message: 'foo', + options: [ + { label: 'Option A', value: 'A' }, + { label: 'Option B', value: 'b' }, + ], + caseSensitive: true, + input, + output, + }); + + input.emit('keypress', 'a', { name: 'a' }); + input.emit('keypress', '', { name: 'escape' }); + + const value = await result; + + expect(output.buffer).toMatchSnapshot(); + expect(prompts.isCancel(value)).toBe(true); + }); + + test('caseSensitive: true makes input case-sensitive', async () => { + const result = prompts.selectKey({ + message: 'foo', + options: [ + { label: 'Option a', value: 'a' }, + { label: 'Option A', value: 'A' }, + { label: 'Option B', value: 'b' }, + ], + caseSensitive: true, + input, + output, + }); + + input.emit('keypress', 'a', { name: 'a', shift: true }); + + const value = await result; + + expect(output.buffer).toMatchSnapshot(); + expect(value).toBe('A'); + }); + + test('long option labels are wrapped correctly', async () => { + output.columns = 40; + + const result = prompts.selectKey({ + message: 'Select an option:', + options: [ + { + label: 'This is a somewhat long label '.repeat(10).trimEnd(), + value: 'a', + }, + { label: 'Short label', value: 'b' }, + ], + input, + output, + }); + + input.emit('keypress', 'a', { name: 'a' }); + + const value = await result; + + expect(output.buffer).toMatchSnapshot(); + expect(value).toBe('a'); + }); + + test('long cancelled labels are wrapped correctly', async () => { + output.columns = 40; + + const result = prompts.selectKey({ + message: 'Select an option:', + options: [ + { + label: 'This is a somewhat long label '.repeat(10).trimEnd(), + value: 'a', + }, + { label: 'Short label', value: 'b' }, + ], + input, + output, + }); + + input.emit('keypress', '', { name: 'escape' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('long submitted labels are wrapped correctly', async () => { + output.columns = 40; + + const result = prompts.selectKey({ + message: 'Select an option:', + options: [ + { + label: 'This is a somewhat long label '.repeat(10).trimEnd(), + value: 'a', + }, + { label: 'Short label', value: 'b' }, + ], + input, + output, + }); + + input.emit('keypress', 'a', { name: 'a' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); +});