diff --git a/.changeset/dark-roses-report.md b/.changeset/dark-roses-report.md new file mode 100644 index 00000000..3c8bb811 --- /dev/null +++ b/.changeset/dark-roses-report.md @@ -0,0 +1,34 @@ +--- +"@clack/prompts": minor +--- + +Add theme support for the text prompt. Users can now customize the colors of symbols, guide lines, and error messages by passing a `theme` option. + +Example usage: +```typescript +import { text } from '@clack/prompts'; +import color from 'picocolors'; + +const result = await text({ + message: 'Enter your name', + theme: { + formatSymbolActive: (str) => color.magenta(str), + formatGuide: (str) => color.blue(str), + formatErrorMessage: (str) => color.bgRed(color.white(str)), + } +}); +``` + +Available theme options for text prompt: +- `formatSymbolActive` - Format the prompt symbol in active/initial state +- `formatSymbolSubmit` - Format the prompt symbol on submit +- `formatSymbolCancel` - Format the prompt symbol on cancel +- `formatSymbolError` - Format the prompt symbol on error +- `formatErrorMessage` - Format error messages +- `formatGuide` - Format the left guide line in active state +- `formatGuideSubmit` - Format the guide line on submit +- `formatGuideCancel` - Format the guide line on cancel +- `formatGuideError` - Format the guide line on error + +This establishes the foundation for theming support that will be extended to other prompts. + diff --git a/examples/basic/text-theme-example.ts b/examples/basic/text-theme-example.ts new file mode 100644 index 00000000..bb2ad674 --- /dev/null +++ b/examples/basic/text-theme-example.ts @@ -0,0 +1,72 @@ +import * as p from '@clack/prompts'; +import color from 'picocolors'; + +async function main() { + console.clear(); + + p.intro(`${color.bgMagenta(color.black(' Custom Themed CLI '))}`); + + // Custom theme with a purple/violet color scheme + // Defaults: active=cyan, submit=green, cancel=red, error=yellow + // Guide defaults: guide=cyan, submit=gray, cancel=gray, error=yellow + const purpleTheme = { + formatSymbolActive: (str: string) => color.magenta(str), // default: cyan + formatSymbolSubmit: (str: string) => color.green(str), // default: green (matching guide) + formatSymbolCancel: (str: string) => color.red(str), // default: red + formatSymbolError: (str: string) => color.yellow(str), // default: yellow + formatGuide: (str: string) => color.magenta(str), // default: cyan + formatGuideSubmit: (str: string) => color.green(str), // default: gray + formatGuideCancel: (str: string) => color.red(str), // default: gray - red for cancel + formatGuideError: (str: string) => color.yellow(str), // default: yellow + formatErrorMessage: (str: string) => color.red(str), // default: yellow + }; + + const name = await p.text({ + message: 'What is your project name?', + placeholder: 'my-awesome-project', + theme: purpleTheme, + validate: (value) => { + if (!value) return 'Project name is required'; + if (value.includes(' ')) return 'Project name cannot contain spaces'; + }, + }); + + if (p.isCancel(name)) { + p.cancel('Setup cancelled.'); + process.exit(0); + } + + const description = await p.text({ + message: 'Describe your project in a few words:', + placeholder: 'A blazing fast CLI tool', + theme: purpleTheme, + }); + + if (p.isCancel(description)) { + p.cancel('Setup cancelled.'); + process.exit(0); + } + + const author = await p.text({ + message: 'Who is the author?', + placeholder: 'Your Name ', + theme: purpleTheme, + validate: (value) => { + if (!value) return 'Author is required'; + }, + }); + + if (p.isCancel(author)) { + p.cancel('Setup cancelled.'); + process.exit(0); + } + + p.note( + `Name: ${color.cyan(name as string)}\nDescription: ${color.cyan((description as string) || 'N/A')}\nAuthor: ${color.cyan(author as string)}`, + 'Project Summary' + ); + + p.outro(`${color.green('✓')} Project ${color.magenta(name as string)} configured!`); +} + +main().catch(console.error); diff --git a/packages/prompts/src/common.ts b/packages/prompts/src/common.ts index 64359053..b96f890b 100644 --- a/packages/prompts/src/common.ts +++ b/packages/prompts/src/common.ts @@ -73,3 +73,38 @@ export interface CommonOptions { signal?: AbortSignal; withGuide?: boolean; } + +export type ColorFormatter = (str: string) => string; + +/** + * Global theme options shared across all prompts. + * These control the common visual elements like the guide line. + */ +export interface GlobalTheme { + /** Format the left guide/border line (default: cyan) */ + formatGuide?: ColorFormatter; + /** Format the guide line on submit (default: gray) */ + formatGuideSubmit?: ColorFormatter; + /** Format the guide line on cancel (default: gray) */ + formatGuideCancel?: ColorFormatter; + /** Format the guide line on error (default: yellow) */ + formatGuideError?: ColorFormatter; +} + +export interface ThemeOptions { + theme?: T & GlobalTheme; +} + +export const defaultGlobalTheme: Required = { + formatGuide: color.cyan, + formatGuideSubmit: color.gray, + formatGuideCancel: color.gray, + formatGuideError: color.yellow, +}; + +export function resolveTheme( + theme: Partial | undefined, + defaults: T +): T { + return { ...defaults, ...theme }; +} diff --git a/packages/prompts/src/text.ts b/packages/prompts/src/text.ts index 2c3dbc8c..a2b0b69f 100644 --- a/packages/prompts/src/text.ts +++ b/packages/prompts/src/text.ts @@ -1,8 +1,48 @@ import { settings, TextPrompt } from '@clack/core'; import color from 'picocolors'; -import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; +import { + type ColorFormatter, + type CommonOptions, + defaultGlobalTheme, + type GlobalTheme, + resolveTheme, + S_BAR, + S_BAR_END, + S_STEP_ACTIVE, + S_STEP_CANCEL, + S_STEP_ERROR, + S_STEP_SUBMIT, + type ThemeOptions, +} from './common.js'; -export interface TextOptions extends CommonOptions { +/** + * Theme options specific to the text prompt. + * All formatters are optional - defaults will be used if not provided. + */ +export interface TextTheme { + /** Format the prompt symbol in active/initial state (default: cyan) */ + formatSymbolActive?: ColorFormatter; + /** Format the prompt symbol on submit (default: green) */ + formatSymbolSubmit?: ColorFormatter; + /** Format the prompt symbol on cancel (default: red) */ + formatSymbolCancel?: ColorFormatter; + /** Format the prompt symbol on error (default: yellow) */ + formatSymbolError?: ColorFormatter; + /** Format error messages (default: yellow) */ + formatErrorMessage?: ColorFormatter; +} + +/** Default theme values for the text prompt */ +const defaultTextTheme: Required = { + ...defaultGlobalTheme, + formatSymbolActive: color.cyan, + formatSymbolSubmit: color.green, + formatSymbolCancel: color.red, + formatSymbolError: color.yellow, + formatErrorMessage: color.yellow, +}; + +export interface TextOptions extends CommonOptions, ThemeOptions { message: string; placeholder?: string; defaultValue?: string; @@ -11,6 +51,8 @@ export interface TextOptions extends CommonOptions { } export const text = (opts: TextOptions) => { + const theme = resolveTheme>(opts.theme, defaultTextTheme); + return new TextPrompt({ validate: opts.validate, placeholder: opts.placeholder, @@ -21,7 +63,38 @@ export const text = (opts: TextOptions) => { input: opts.input, render() { const hasGuide = (opts?.withGuide ?? settings.withGuide) !== false; - const titlePrefix = `${hasGuide ? `${color.gray(S_BAR)}\n` : ''}${symbol(this.state)} `; + + // Resolve symbol based on state + const symbolText = (() => { + switch (this.state) { + case 'initial': + case 'active': + return theme.formatSymbolActive(S_STEP_ACTIVE); + case 'cancel': + return theme.formatSymbolCancel(S_STEP_CANCEL); + case 'error': + return theme.formatSymbolError(S_STEP_ERROR); + case 'submit': + return theme.formatSymbolSubmit(S_STEP_SUBMIT); + } + })(); + + // Resolve connector bar color based on state + const connectorBar = (() => { + switch (this.state) { + case 'initial': + case 'active': + return theme.formatGuide(S_BAR); + case 'cancel': + return theme.formatGuideCancel(S_BAR); + case 'error': + return theme.formatGuideError(S_BAR); + case 'submit': + return theme.formatGuideSubmit(S_BAR); + } + })(); + + const titlePrefix = `${hasGuide ? `${connectorBar}\n` : ''}${symbolText} `; const title = `${titlePrefix}${opts.message}\n`; const placeholder = opts.placeholder ? color.inverse(opts.placeholder[0]) + color.dim(opts.placeholder.slice(1)) @@ -31,24 +104,24 @@ export const text = (opts: TextOptions) => { switch (this.state) { case 'error': { - const errorText = this.error ? ` ${color.yellow(this.error)}` : ''; - const errorPrefix = hasGuide ? `${color.yellow(S_BAR)} ` : ''; - const errorPrefixEnd = hasGuide ? color.yellow(S_BAR_END) : ''; + const errorText = this.error ? ` ${theme.formatErrorMessage(this.error)}` : ''; + const errorPrefix = hasGuide ? `${theme.formatGuideError(S_BAR)} ` : ''; + const errorPrefixEnd = hasGuide ? theme.formatGuideError(S_BAR_END) : ''; return `${title.trim()}\n${errorPrefix}${userInput}\n${errorPrefixEnd}${errorText}\n`; } case 'submit': { const valueText = value ? ` ${color.dim(value)}` : ''; - const submitPrefix = hasGuide ? color.gray(S_BAR) : ''; + const submitPrefix = hasGuide ? theme.formatGuideSubmit(S_BAR) : ''; return `${title}${submitPrefix}${valueText}`; } case 'cancel': { const valueText = value ? ` ${color.strikethrough(color.dim(value))}` : ''; - const cancelPrefix = hasGuide ? color.gray(S_BAR) : ''; + const cancelPrefix = hasGuide ? theme.formatGuideCancel(S_BAR) : ''; return `${title}${cancelPrefix}${valueText}${value.trim() ? `\n${cancelPrefix}` : ''}`; } default: { - const defaultPrefix = hasGuide ? `${color.cyan(S_BAR)} ` : ''; - const defaultPrefixEnd = hasGuide ? color.cyan(S_BAR_END) : ''; + const defaultPrefix = hasGuide ? `${theme.formatGuide(S_BAR)} ` : ''; + const defaultPrefixEnd = hasGuide ? theme.formatGuide(S_BAR_END) : ''; return `${title}${defaultPrefix}${userInput}\n${defaultPrefixEnd}\n`; } } diff --git a/packages/prompts/test/__snapshots__/text.test.ts.snap b/packages/prompts/test/__snapshots__/text.test.ts.snap index 43c38c78..9a3600c2 100644 --- a/packages/prompts/test/__snapshots__/text.test.ts.snap +++ b/packages/prompts/test/__snapshots__/text.test.ts.snap @@ -3,10 +3,10 @@ exports[`text (isCI = false) > can be aborted by a signal 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", " ", @@ -17,16 +17,16 @@ exports[`text (isCI = false) > can be aborted by a signal 1`] = ` exports[`text (isCI = false) > can cancel 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "■ foo -│", + "■ foo +│", " ", "", @@ -36,16 +36,16 @@ exports[`text (isCI = false) > can cancel 1`] = ` exports[`text (isCI = false) > defaultValue sets the value but does not render 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "◇ foo -│ bar", + "◇ foo +│ bar", " ", "", @@ -55,16 +55,16 @@ exports[`text (isCI = false) > defaultValue sets the value but does not render 1 exports[`text (isCI = false) > empty string when no value and no default 1`] = ` [ "", - "│ -◆ foo -│   (hit Enter to use default) -└ + "│ +◆ foo +│ (hit Enter to use default) +└ ", "", "", "", - "◇ foo -│", + "◇ foo +│", " ", "", @@ -74,13 +74,13 @@ exports[`text (isCI = false) > empty string when no value and no default 1`] = ` exports[`text (isCI = false) > global withGuide: false removes guide 1`] = ` [ "", - "◆ foo -_ + "◆ foo +_ ", "", "", - "◇ foo + "◇ foo ", " ", @@ -91,16 +91,16 @@ exports[`text (isCI = false) > global withGuide: false removes guide 1`] = ` exports[`text (isCI = false) > placeholder is not used as value when pressing enter 1`] = ` [ "", - "│ -◆ foo -│   (hit Enter to use default) -└ + "│ +◆ foo +│ (hit Enter to use default) +└ ", "", "", "", - "◇ foo -│ default-value", + "◇ foo +│ default-value", " ", "", @@ -110,27 +110,27 @@ exports[`text (isCI = false) > placeholder is not used as value when pressing en exports[`text (isCI = false) > renders cancelled value if one set 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "│ x█", + "│ x█", "", "", "", "", - "│ xy█", + "│ xy█", "", "", "", "", - "■ foo -│ xy -│", + "■ foo +│ xy +│", " ", "", @@ -140,16 +140,16 @@ exports[`text (isCI = false) > renders cancelled value if one set 1`] = ` exports[`text (isCI = false) > renders message 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "◇ foo -│", + "◇ foo +│", " ", "", @@ -159,16 +159,16 @@ exports[`text (isCI = false) > renders message 1`] = ` exports[`text (isCI = false) > renders placeholder if set 1`] = ` [ "", - "│ -◆ foo -│ bar -└ + "│ +◆ foo +│ bar +└ ", "", "", "", - "◇ foo -│", + "◇ foo +│", " ", "", @@ -178,26 +178,26 @@ exports[`text (isCI = false) > renders placeholder if set 1`] = ` exports[`text (isCI = false) > renders submitted value 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "│ x█", + "│ x█", "", "", "", "", - "│ xy█", + "│ xy█", "", "", "", "", - "◇ foo -│ xy", + "◇ foo +│ xy", " ", "", @@ -207,35 +207,35 @@ exports[`text (isCI = false) > renders submitted value 1`] = ` exports[`text (isCI = false) > validation errors render and clear (using Error) 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "│ x█", + "│ x█", "", "", "", "", - "▲ foo -│ x█ -└ should be xy + "▲ foo +│ x█ +└ should be xy ", "", "", "", - "◆ foo -│ xy█ -└ + "◆ foo +│ xy█ +└ ", "", "", "", - "◇ foo -│ xy", + "◇ foo +│ xy", " ", "", @@ -245,35 +245,35 @@ exports[`text (isCI = false) > validation errors render and clear (using Error) exports[`text (isCI = false) > validation errors render and clear 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "│ x█", + "│ x█", "", "", "", "", - "▲ foo -│ x█ -└ should be xy + "▲ foo +│ x█ +└ should be xy ", "", "", "", - "◆ foo -│ xy█ -└ + "◆ foo +│ xy█ +└ ", "", "", "", - "◇ foo -│ xy", + "◇ foo +│ xy", " ", "", @@ -283,13 +283,13 @@ exports[`text (isCI = false) > validation errors render and clear 1`] = ` exports[`text (isCI = false) > withGuide: false removes guide 1`] = ` [ "", - "◆ foo -_ + "◆ foo +_ ", "", "", - "◇ foo + "◇ foo ", " ", @@ -300,10 +300,10 @@ exports[`text (isCI = false) > withGuide: false removes guide 1`] = ` exports[`text (isCI = true) > can be aborted by a signal 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", " ", @@ -314,16 +314,16 @@ exports[`text (isCI = true) > can be aborted by a signal 1`] = ` exports[`text (isCI = true) > can cancel 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "■ foo -│", + "■ foo +│", " ", "", @@ -333,16 +333,16 @@ exports[`text (isCI = true) > can cancel 1`] = ` exports[`text (isCI = true) > defaultValue sets the value but does not render 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "◇ foo -│ bar", + "◇ foo +│ bar", " ", "", @@ -352,16 +352,16 @@ exports[`text (isCI = true) > defaultValue sets the value but does not render 1` exports[`text (isCI = true) > empty string when no value and no default 1`] = ` [ "", - "│ -◆ foo -│   (hit Enter to use default) -└ + "│ +◆ foo +│ (hit Enter to use default) +└ ", "", "", "", - "◇ foo -│", + "◇ foo +│", " ", "", @@ -371,13 +371,13 @@ exports[`text (isCI = true) > empty string when no value and no default 1`] = ` exports[`text (isCI = true) > global withGuide: false removes guide 1`] = ` [ "", - "◆ foo -_ + "◆ foo +_ ", "", "", - "◇ foo + "◇ foo ", " ", @@ -388,16 +388,16 @@ exports[`text (isCI = true) > global withGuide: false removes guide 1`] = ` exports[`text (isCI = true) > placeholder is not used as value when pressing enter 1`] = ` [ "", - "│ -◆ foo -│   (hit Enter to use default) -└ + "│ +◆ foo +│ (hit Enter to use default) +└ ", "", "", "", - "◇ foo -│ default-value", + "◇ foo +│ default-value", " ", "", @@ -407,27 +407,27 @@ exports[`text (isCI = true) > placeholder is not used as value when pressing ent exports[`text (isCI = true) > renders cancelled value if one set 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "│ x█", + "│ x█", "", "", "", "", - "│ xy█", + "│ xy█", "", "", "", "", - "■ foo -│ xy -│", + "■ foo +│ xy +│", " ", "", @@ -437,16 +437,16 @@ exports[`text (isCI = true) > renders cancelled value if one set 1`] = ` exports[`text (isCI = true) > renders message 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "◇ foo -│", + "◇ foo +│", " ", "", @@ -456,16 +456,16 @@ exports[`text (isCI = true) > renders message 1`] = ` exports[`text (isCI = true) > renders placeholder if set 1`] = ` [ "", - "│ -◆ foo -│ bar -└ + "│ +◆ foo +│ bar +└ ", "", "", "", - "◇ foo -│", + "◇ foo +│", " ", "", @@ -475,26 +475,26 @@ exports[`text (isCI = true) > renders placeholder if set 1`] = ` exports[`text (isCI = true) > renders submitted value 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "│ x█", + "│ x█", "", "", "", "", - "│ xy█", + "│ xy█", "", "", "", "", - "◇ foo -│ xy", + "◇ foo +│ xy", " ", "", @@ -504,35 +504,35 @@ exports[`text (isCI = true) > renders submitted value 1`] = ` exports[`text (isCI = true) > validation errors render and clear (using Error) 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "│ x█", + "│ x█", "", "", "", "", - "▲ foo -│ x█ -└ should be xy + "▲ foo +│ x█ +└ should be xy ", "", "", "", - "◆ foo -│ xy█ -└ + "◆ foo +│ xy█ +└ ", "", "", "", - "◇ foo -│ xy", + "◇ foo +│ xy", " ", "", @@ -542,35 +542,35 @@ exports[`text (isCI = true) > validation errors render and clear (using Error) 1 exports[`text (isCI = true) > validation errors render and clear 1`] = ` [ "", - "│ -◆ foo -│ _ -└ + "│ +◆ foo +│ _ +└ ", "", "", "", - "│ x█", + "│ x█", "", "", "", "", - "▲ foo -│ x█ -└ should be xy + "▲ foo +│ x█ +└ should be xy ", "", "", "", - "◆ foo -│ xy█ -└ + "◆ foo +│ xy█ +└ ", "", "", "", - "◇ foo -│ xy", + "◇ foo +│ xy", " ", "", @@ -580,13 +580,13 @@ exports[`text (isCI = true) > validation errors render and clear 1`] = ` exports[`text (isCI = true) > withGuide: false removes guide 1`] = ` [ "", - "◆ foo -_ + "◆ foo +_ ", "", "", - "◇ foo + "◇ foo ", " ",