From fcedb259e91be37a5539b939cca129669a9157fd Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Sat, 27 Jan 2024 16:28:27 -0500 Subject: [PATCH 1/2] Chore: Refactor usePrefix to rely on a new Theme construct --- packages/core/README.md | 72 ++++++++++++++++++- packages/core/src/index.mts | 2 + packages/core/src/lib/make-theme.mts | 10 +++ .../src/lib/pagination/use-pagination.mts | 10 ++- packages/core/src/lib/theme.mts | 39 ++++++++++ packages/core/src/lib/use-prefix.mts | 19 +++-- packages/type/src/index.mts | 6 ++ 7 files changed, 147 insertions(+), 11 deletions(-) create mode 100644 packages/core/src/lib/make-theme.mts create mode 100644 packages/core/src/lib/theme.mts diff --git a/packages/core/README.md b/packages/core/README.md index d5f75e3fb..bd1438223 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -28,7 +28,7 @@ const confirm = createPrompt( (config, done) => { const [status, setStatus] = useState('pending'); const [value, setValue] = useState(''); - const prefix = usePrefix(); + const prefix = usePrefix({}); useKeypress((key, rl) => { if (isEnterKey(key)) { @@ -141,12 +141,82 @@ export default createPrompt((config, done) => { renderItem: ({ item, index, isActive }) => `${isActive ? ">" : " "}${index}. ${item.toString()}` pageSize: config.pageSize, loop: config.loop, + theme, config.theme, }); return `... ${page}`; }); ``` +### Theming + +Theming utilities will allow you to expose customization of the prompt style. Inquirer also has a few standard theme values shared across all the official prompts. + +To allow standard customization: + +```ts +import { createPrompt, usePrefix, makeTheme, type Theme } from '@inquirer/core'; +import type { PartialDeep } from '@inquirer/type'; + +type PromptConfig = { + theme?: PartialDeep; +}; + +export default createPrompt((config, done) => { + const theme = makeTheme(config.theme); + + const prefix = usePrefix({ isLoading, theme }); + + return `${prefix} ${theme.style.highlight('hello')}`; +}); +``` + +To setup a custom theme: + +```ts +import { createPrompt, makeTheme, type Theme } from '@inquirer/core'; +import type { PartialDeep } from '@inquirer/type'; + +type PromptTheme = {}; + +const promptTheme: PromptTheme = { + icon: '!', +}; + +type PromptConfig = { + theme?: PartialDeep>; +}; + +export default createPrompt((config, done) => { + const theme = makeTheme(promptTheme, config.theme); + + const prefix = usePrefix({ isLoading, theme }); + + return `${prefix} ${theme.icon}`; +}); +``` + +The [default theme keys cover](https://github.com/SBoudrias/Inquirer.js/blob/theme/packages/core/src/lib/theme.mts): + +```ts +type DefaultTheme = { + prefix: string; + spinner: { + interval: number; + frames: string[]; + }; + style: { + answer: (text: string) => string; + message: (text: string) => string; + error: (text: string) => string; + defaultAnswer: (text: string) => string; + help: (text: string) => string; + highlight: (text: string) => string; + key: (text: string) => string; + }; +}; +``` + # License Copyright (c) 2023 Simon Boudrias (twitter: [@vaxilart](https://twitter.com/Vaxilart))
diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index 71469a0bb..b0434d99c 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -5,6 +5,8 @@ export { useEffect } from './lib/use-effect.mjs'; export { useMemo } from './lib/use-memo.mjs'; export { useRef } from './lib/use-ref.mjs'; export { useKeypress } from './lib/use-keypress.mjs'; +export { makeTheme } from './lib/make-theme.mjs'; +export type { Theme } from './lib/theme.mjs'; export { usePagination } from './lib/pagination/use-pagination.mjs'; export { createPrompt } from './lib/create-prompt.mjs'; export { Separator } from './lib/Separator.mjs'; diff --git a/packages/core/src/lib/make-theme.mts b/packages/core/src/lib/make-theme.mts new file mode 100644 index 000000000..991c2be5b --- /dev/null +++ b/packages/core/src/lib/make-theme.mts @@ -0,0 +1,10 @@ +import type { Prettify, PartialDeep } from '@inquirer/type'; +import { defaultTheme, type Theme } from './theme.mjs'; + +export function makeTheme( + ...themes: ReadonlyArray>> +): Prettify> { + return Object.assign({}, defaultTheme, ...themes, { + style: Object.assign({}, defaultTheme.style, ...themes.map((theme) => theme?.style)), + }); +} diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts index f63db8690..fa9d86431 100644 --- a/packages/core/src/lib/pagination/use-pagination.mts +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -1,7 +1,8 @@ -import chalk from 'chalk'; -import { type Prettify } from '@inquirer/type'; +import type { Prettify } from '@inquirer/type'; import { useRef } from '../use-ref.mjs'; import { readlineWidth } from '../utils.mjs'; +import { makeTheme } from '../make-theme.mjs'; +import { type Theme } from '../theme.mjs'; import { lines, type Layout } from './lines.mjs'; import { finite, infinite } from './position.mjs'; @@ -11,6 +12,7 @@ export function usePagination({ renderItem, pageSize, loop = true, + theme: defaultTheme, }: { items: readonly T[]; /** The index of the active item. */ @@ -21,8 +23,10 @@ export function usePagination({ pageSize: number; /** Allows creating an infinitely looping list. `true` if unspecified. */ loop?: boolean; + theme?: Theme; }): string { const state = useRef({ position: 0, lastActive: 0 }); + const theme = makeTheme(defaultTheme); const position = loop ? infinite({ @@ -51,7 +55,7 @@ export function usePagination({ }).join('\n'); if (items.length > pageSize) { - return `${visibleLines}\n${chalk.dim('(Use arrow keys to reveal more choices)')}`; + return `${visibleLines}\n${theme.style.help('(Use arrow keys to reveal more choices)')}`; } return visibleLines; diff --git a/packages/core/src/lib/theme.mts b/packages/core/src/lib/theme.mts new file mode 100644 index 000000000..546e853a8 --- /dev/null +++ b/packages/core/src/lib/theme.mts @@ -0,0 +1,39 @@ +import chalk from 'chalk'; +import spinners from 'cli-spinners'; +import type { Prettify } from '@inquirer/type'; + +type DefaultTheme = { + prefix: string; + spinner: { + interval: number; + frames: string[]; + }; + style: { + answer: (text: string) => string; + message: (text: string) => string; + error: (text: string) => string; + defaultAnswer: (text: string) => string; + help: (text: string) => string; + highlight: (text: string) => string; + key: (text: string) => string; + }; +}; + +export type Theme = Prettify; + +export const defaultTheme: DefaultTheme = { + prefix: chalk.green('?'), + spinner: { + interval: spinners.dots.interval, + frames: spinners.dots.frames.map(chalk.yellow), + }, + style: { + answer: chalk.cyan, + message: chalk.bold, + error: (text) => chalk.red(`> ${text}`), + defaultAnswer: (text) => chalk.dim(`(${text})`), + help: chalk.dim, + highlight: chalk.cyan, + key: (text: string) => chalk.cyan.bold(`<${text}>`), + }, +}; diff --git a/packages/core/src/lib/use-prefix.mts b/packages/core/src/lib/use-prefix.mts index d123b42e5..34623ea14 100644 --- a/packages/core/src/lib/use-prefix.mts +++ b/packages/core/src/lib/use-prefix.mts @@ -1,12 +1,17 @@ -import chalk from 'chalk'; -import spinners from 'cli-spinners'; import { useState } from './use-state.mjs'; import { useEffect } from './use-effect.mjs'; +import { makeTheme } from './make-theme.mjs'; +import { type Theme } from './theme.mjs'; -const spinner = spinners.dots; - -export function usePrefix(isLoading: boolean = false): string { +export function usePrefix({ + isLoading = false, + theme, +}: { + isLoading?: boolean; + theme?: Theme; +}): string { const [tick, setTick] = useState(0); + const { prefix, spinner } = makeTheme(theme); useEffect((): void | (() => unknown) => { if (isLoading) { @@ -20,8 +25,8 @@ export function usePrefix(isLoading: boolean = false): string { if (isLoading) { const frame = tick % spinner.frames.length; - return chalk.yellow(spinner.frames[frame]); + return spinner.frames[frame]!; } - return chalk.green('?'); + return prefix; } diff --git a/packages/type/src/index.mts b/packages/type/src/index.mts index e4c5fba44..ecd7f091c 100644 --- a/packages/type/src/index.mts +++ b/packages/type/src/index.mts @@ -6,6 +6,12 @@ export type Prettify = { [K in keyof T]: T[K]; } & {}; +export type PartialDeep = T extends object + ? { + [P in keyof T]?: PartialDeep; + } + : T; + export type Context = { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream; From ab02f5f541b15d2534d4b632a27145bdd079bfc6 Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Sat, 27 Jan 2024 16:29:16 -0500 Subject: [PATCH 2/2] Feat: Apply theming to all prompts --- packages/checkbox/README.md | 30 ++++++++++++ packages/checkbox/src/index.mts | 81 +++++++++++++++++++++------------ packages/confirm/README.md | 30 ++++++++++-- packages/confirm/package.json | 3 +- packages/confirm/src/index.mts | 17 ++++--- packages/editor/README.md | 21 +++++++++ packages/editor/package.json | 1 - packages/editor/src/index.mts | 26 ++++++----- packages/expand/README.md | 22 +++++++++ packages/expand/src/index.mts | 18 +++++--- packages/input/README.md | 21 +++++++++ packages/input/input.test.mts | 34 ++++++++++++++ packages/input/package.json | 3 +- packages/input/src/index.mts | 17 ++++--- packages/password/README.md | 21 +++++++++ packages/password/package.json | 3 +- packages/password/src/index.mts | 17 ++++--- packages/rawlist/README.md | 21 +++++++++ packages/rawlist/src/index.mts | 16 ++++--- packages/select/README.md | 26 +++++++++++ packages/select/src/index.mts | 65 ++++++++++++++++---------- yarn.lock | 4 -- 22 files changed, 385 insertions(+), 112 deletions(-) diff --git a/packages/checkbox/README.md b/packages/checkbox/README.md index 1afcd8e4e..f336a21ac 100644 --- a/packages/checkbox/README.md +++ b/packages/checkbox/README.md @@ -43,9 +43,39 @@ const answer = await checkbox({ | loop | `boolean` | no | Defaults to `true`. When set to `false`, the cursor will be constrained to the top and bottom of the choice list without looping. | | required | `boolean` | no | When set to `true`, ensures at least one choice must be selected. | | validate | `string\[\] => boolean \| string \| Promise` | no | On submit, validate the choices. When returning a string, it'll be used as the error message displayed to the user. Note: returning a rejected promise, we'll assume a code error happened and crash. | +| theme | [See Theming](#Theming) | no | Customize look of the prompt. | The `Separator` object can be used to render non-selectable lines in the choice list. By default it'll render a line, but you can provide the text as argument (`new Separator('-- Dependencies --')`). This option is often used to add labels to groups within long list of options. +## Theming + +You can theme a prompt by passing a `theme` object option. The theme object only need to includes the keys you wish to modify, we'll fallback on the defaults for the rest. + +```ts +type Theme = { + prefix: string; + spinner: { + interval: number; + frames: string[]; + }; + style: { + answer: (text: string) => string; + message: (text: string) => string; + error: (text: string) => string; + defaultAnswer: (text: string) => string; + help: (text: string) => string; + highlight: (text: string) => string; + key: (text: string) => string; + disabledChoice: (text: string) => string; + }; + icon: { + checked: string; + unchecked: string; + cursor: string; + }; +}; +``` + # License Copyright (c) 2023 Simon Boudrias (twitter: [@vaxilart](https://twitter.com/Vaxilart))
diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index 5b70675d5..4ed8e0648 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -5,18 +5,42 @@ import { usePrefix, usePagination, useMemo, + makeTheme, isUpKey, isDownKey, isSpaceKey, isNumberKey, isEnterKey, Separator, + type Theme, } from '@inquirer/core'; -import type {} from '@inquirer/type'; +import type { PartialDeep } from '@inquirer/type'; import chalk from 'chalk'; import figures from 'figures'; import ansiEscapes from 'ansi-escapes'; +type CheckboxTheme = { + icon: { + checked: string; + unchecked: string; + cursor: string; + }; + style: { + disabledChoice: (text: string) => string; + }; +}; + +const checkboxTheme: CheckboxTheme = { + icon: { + checked: chalk.green(figures.circleFilled), + unchecked: figures.circle, + cursor: figures.pointer, + }, + style: { + disabledChoice: (text: string) => chalk.dim(`- ${text}`), + }, +}; + type Choice = { name?: string; value: Value; @@ -36,6 +60,7 @@ type Config = { validate?: ( items: ReadonlyArray>, ) => boolean | string | Promise; + theme?: PartialDeep>; }; type Item = Separator | Choice; @@ -58,28 +83,9 @@ function check(checked: boolean) { }; } -function renderItem({ item, isActive }: { item: Item; isActive: boolean }) { - if (Separator.isSeparator(item)) { - return ` ${item.separator}`; - } - - const line = item.name || item.value; - if (item.disabled) { - const disabledLabel = - typeof item.disabled === 'string' ? item.disabled : '(disabled)'; - return chalk.dim(`- ${line} ${disabledLabel}`); - } - - const checkbox = item.checked ? chalk.green(figures.circleFilled) : figures.circle; - const color = isActive ? chalk.cyan : (x: string) => x; - const prefix = isActive ? figures.pointer : ' '; - return color(`${prefix}${checkbox} ${line}`); -} - export default createPrompt( (config: Config, done: (value: Array) => void) => { const { - prefix = usePrefix(), instructions, pageSize = 7, loop = true, @@ -87,6 +93,8 @@ export default createPrompt( required, validate = () => true, } = config; + const theme = makeTheme(checkboxTheme, config.theme); + const prefix = usePrefix({ theme }); const [status, setStatus] = useState('pending'); const [items, setItems] = useState>>( choices.map((choice) => ({ ...choice })), @@ -157,21 +165,38 @@ export default createPrompt( } }); - const message = chalk.bold(config.message); + const message = theme.style.message(config.message); const page = usePagination>({ items, active, - renderItem, + renderItem({ item, isActive }: { item: Item; isActive: boolean }) { + if (Separator.isSeparator(item)) { + return ` ${item.separator}`; + } + + const line = item.name || item.value; + if (item.disabled) { + const disabledLabel = + typeof item.disabled === 'string' ? item.disabled : '(disabled)'; + return theme.style.disabledChoice(`${line} ${disabledLabel}`); + } + + const checkbox = item.checked ? theme.icon.checked : theme.icon.unchecked; + const color = isActive ? theme.style.highlight : (x: string) => x; + const cursor = isActive ? theme.icon.cursor : ' '; + return color(`${cursor}${checkbox} ${line}`); + }, pageSize, loop, + theme, }); if (status === 'done') { const selection = items .filter(isChecked) .map((choice) => choice.name || choice.value); - return `${prefix} ${message} ${chalk.cyan(selection.join(', '))}`; + return `${prefix} ${message} ${theme.style.answer(selection.join(', '))}`; } let helpTip = ''; @@ -180,10 +205,10 @@ export default createPrompt( helpTip = instructions; } else { const keys = [ - `${chalk.cyan.bold('')} to select`, - `${chalk.cyan.bold('')} to toggle all`, - `${chalk.cyan.bold('')} to invert selection`, - `and ${chalk.cyan.bold('')} to proceed`, + `${theme.style.key('space')} to select`, + `${theme.style.key('a')} to toggle all`, + `${theme.style.key('i')} to invert selection`, + `and ${theme.style.key('enter')} to proceed`, ]; helpTip = ` (Press ${keys.join(', ')})`; } @@ -191,7 +216,7 @@ export default createPrompt( let error = ''; if (errorMsg) { - error = chalk.red(`> ${errorMsg}`); + error = theme.style.error(errorMsg); } return `${prefix} ${message}${helpTip}\n${page}\n${error}${ansiEscapes.cursorHide}`; diff --git a/packages/confirm/README.md b/packages/confirm/README.md index ae7818e23..4d4371d60 100644 --- a/packages/confirm/README.md +++ b/packages/confirm/README.md @@ -22,11 +22,31 @@ const answer = await confirm({ message: 'Continue?' }); ## Options -| Property | Type | Required | Description | -| ----------- | --------------------- | -------- | ------------------------------------------------------- | -| message | `string` | yes | The question to ask | -| default | `boolean` | no | Default answer (true or false) | -| transformer | `(boolean) => string` | no | Transform the prompt printed message to a custom string | +| Property | Type | Required | Description | +| ----------- | ----------------------- | -------- | ------------------------------------------------------- | +| message | `string` | yes | The question to ask | +| default | `boolean` | no | Default answer (true or false) | +| transformer | `(boolean) => string` | no | Transform the prompt printed message to a custom string | +| theme | [See Theming](#Theming) | no | Customize look of the prompt. | + +## Theming + +You can theme a prompt by passing a `theme` object option. The theme object only need to includes the keys you wish to modify, we'll fallback on the defaults for the rest. + +```ts +type Theme = { + prefix: string; + spinner: { + interval: number; + frames: string[]; + }; + style: { + answer: (text: string) => string; + message: (text: string) => string; + defaultAnswer: (text: string) => string; + }; +}; +``` # License diff --git a/packages/confirm/package.json b/packages/confirm/package.json index 3767ddee3..b3cf779ce 100644 --- a/packages/confirm/package.json +++ b/packages/confirm/package.json @@ -55,8 +55,7 @@ "homepage": "https://github.com/SBoudrias/Inquirer.js/blob/master/packages/confirm/README.md", "dependencies": { "@inquirer/core": "^6.0.0", - "@inquirer/type": "^1.1.6", - "chalk": "^4.1.2" + "@inquirer/type": "^1.1.6" }, "devDependencies": { "@inquirer/testing": "^2.1.10" diff --git a/packages/confirm/src/index.mts b/packages/confirm/src/index.mts index 5c8faa575..ed01ec53a 100644 --- a/packages/confirm/src/index.mts +++ b/packages/confirm/src/index.mts @@ -1,24 +1,27 @@ -import chalk from 'chalk'; import { createPrompt, useState, useKeypress, isEnterKey, usePrefix, + makeTheme, + type Theme, } from '@inquirer/core'; -import type {} from '@inquirer/type'; +import type { PartialDeep } from '@inquirer/type'; type ConfirmConfig = { message: string; default?: boolean; transformer?: (value: boolean) => string; + theme?: PartialDeep; }; export default createPrompt((config, done) => { const { transformer = (answer) => (answer ? 'yes' : 'no') } = config; const [status, setStatus] = useState('pending'); const [value, setValue] = useState(''); - const prefix = usePrefix(); + const theme = makeTheme(config.theme); + const prefix = usePrefix({ theme }); useKeypress((key, rl) => { if (isEnterKey(key)) { @@ -37,11 +40,13 @@ export default createPrompt((config, done) => { let formattedValue = value; let defaultValue = ''; if (status === 'done') { - formattedValue = chalk.cyan(value); + formattedValue = theme.style.answer(value); } else { - defaultValue = chalk.dim(config.default === false ? ' (y/N)' : ' (Y/n)'); + defaultValue = ` ${theme.style.defaultAnswer( + config.default === false ? 'y/N' : 'Y/n', + )}`; } - const message = chalk.bold(config.message); + const message = theme.style.message(config.message); return `${prefix} ${message}${defaultValue} ${formattedValue}`; }); diff --git a/packages/editor/README.md b/packages/editor/README.md index de583311d..61374365c 100644 --- a/packages/editor/README.md +++ b/packages/editor/README.md @@ -29,6 +29,27 @@ const answer = await editor({ | validate | `string => boolean \| string \| Promise` | no | On submit, validate the content. When returning a string, it'll be used as the error message displayed to the user. Note: returning a rejected promise, we'll assume a code error happened and crash. | | postfix | `string` | no (default to `.txt`) | The postfix of the file being edited. Adding this will add color highlighting to the file content in most editors. | | waitForUseInput | `boolean` | no (default to `true`) | Open the editor automatically without waiting for the user to press enter. Note that this mean the user will not see the question! So make sure you have a default value that provide guidance if it's unclear what input is expected. | +| theme | [See Theming](#Theming) | no | Customize look of the prompt. | + +## Theming + +You can theme a prompt by passing a `theme` object option. The theme object only need to includes the keys you wish to modify, we'll fallback on the defaults for the rest. + +```ts +type Theme = { + prefix: string; + spinner: { + interval: number; + frames: string[]; + }; + style: { + message: (text: string) => string; + error: (text: string) => string; + help: (text: string) => string; + key: (text: string) => string; + }; +}; +``` # License diff --git a/packages/editor/package.json b/packages/editor/package.json index 4a36e5504..82f9b8094 100644 --- a/packages/editor/package.json +++ b/packages/editor/package.json @@ -56,7 +56,6 @@ "dependencies": { "@inquirer/core": "^6.0.0", "@inquirer/type": "^1.1.6", - "chalk": "^4.1.2", "external-editor": "^3.1.0" }, "scripts": { diff --git a/packages/editor/src/index.mts b/packages/editor/src/index.mts index 7756094a6..4c8136e7e 100644 --- a/packages/editor/src/index.mts +++ b/packages/editor/src/index.mts @@ -1,4 +1,3 @@ -import chalk from 'chalk'; import { editAsync } from 'external-editor'; import { createPrompt, @@ -7,9 +6,11 @@ import { useKeypress, usePrefix, isEnterKey, + makeTheme, type InquirerReadline, + type Theme, } from '@inquirer/core'; -import type {} from '@inquirer/type'; +import type { PartialDeep } from '@inquirer/type'; type EditorConfig = { message: string; @@ -17,14 +18,20 @@ type EditorConfig = { postfix?: string; waitForUseInput?: boolean; validate?: (value: string) => boolean | string | Promise; + theme?: PartialDeep; }; export default createPrompt((config, done) => { const { waitForUseInput = true, validate = () => true } = config; + const theme = makeTheme(config.theme); + const [status, setStatus] = useState('pending'); const [value, setValue] = useState(config.default || ''); const [errorMsg, setError] = useState(undefined); + const isLoading = status === 'loading'; + const prefix = usePrefix({ isLoading, theme }); + function startEditor(rl: InquirerReadline) { rl.pause(); editAsync( @@ -70,21 +77,18 @@ export default createPrompt((config, done) => { } }); - const isLoading = status === 'loading'; - const prefix = usePrefix(isLoading); - - const message = chalk.bold(config.message); - - let helpTip; + const message = theme.style.message(config.message); + let helpTip = ''; if (status === 'loading') { - helpTip = chalk.dim('Received'); + helpTip = theme.style.help('Received'); } else if (status === 'pending') { - helpTip = chalk.dim('Press to launch your preferred editor.'); + const enterKey = theme.style.key('enter'); + helpTip = theme.style.help(`Press ${enterKey} to launch your preferred editor.`); } let error = ''; if (errorMsg) { - error = chalk.red(`> ${errorMsg}`); + error = theme.style.error(errorMsg); } return [[prefix, message, helpTip].filter(Boolean).join(' '), error]; diff --git a/packages/expand/README.md b/packages/expand/README.md index 884c0b9cc..13ddf127d 100644 --- a/packages/expand/README.md +++ b/packages/expand/README.md @@ -54,6 +54,28 @@ const answer = await expand({ | choices | `Array<{ key: string, name: string, value?: string }>` | yes | Array of the different allowed choices. The `h`/help option is always provided by default | | default | `string` | no | Default choices to be selected. (value must be one of the choices `key`) | | expanded | `boolean` | no | Expand the choices by default | +| theme | [See Theming](#Theming) | no | Customize look of the prompt. | + +## Theming + +You can theme a prompt by passing a `theme` object option. The theme object only need to includes the keys you wish to modify, we'll fallback on the defaults for the rest. + +```ts +type Theme = { + prefix: string; + spinner: { + interval: number; + frames: string[]; + }; + style: { + answer: (text: string) => string; + message: (text: string) => string; + error: (text: string) => string; + defaultAnswer: (text: string) => string; + highlight: (text: string) => string; + }; +}; +``` # License diff --git a/packages/expand/src/index.mts b/packages/expand/src/index.mts index 50f5f6c82..e36455190 100644 --- a/packages/expand/src/index.mts +++ b/packages/expand/src/index.mts @@ -4,8 +4,10 @@ import { useKeypress, usePrefix, isEnterKey, + makeTheme, + type Theme, } from '@inquirer/core'; -import type {} from '@inquirer/type'; +import type { PartialDeep } from '@inquirer/type'; import chalk from 'chalk'; type ExpandChoice = @@ -18,6 +20,7 @@ type ExpandConfig = { choices: ReadonlyArray; default?: string; expanded?: boolean; + theme?: PartialDeep; }; const helpChoice = { @@ -46,7 +49,8 @@ export default createPrompt((config, done) => { const [value, setValue] = useState(''); const [expanded, setExpanded] = useState(defaultExpandState); const [errorMsg, setError] = useState(undefined); - const prefix = usePrefix(); + const theme = makeTheme(config.theme); + const prefix = usePrefix({ theme }); useKeypress((event, rl) => { if (isEnterKey(event)) { @@ -72,11 +76,11 @@ export default createPrompt((config, done) => { } }); - const message = chalk.bold(config.message); + const message = theme.style.message(config.message); if (status === 'done') { // TODO: `value` should be the display name instead of the raw value. - return `${prefix} ${message} ${chalk.cyan(value)}`; + return `${prefix} ${message} ${theme.style.answer(value)}`; } const allChoices = expanded ? choices : [...choices, helpChoice]; @@ -92,7 +96,7 @@ export default createPrompt((config, done) => { return choice.key; }) .join(''); - shortChoices = chalk.dim(` (${shortChoices})`); + shortChoices = ` ${theme.style.defaultAnswer(shortChoices)}`; // Expanded display style if (expanded) { @@ -101,7 +105,7 @@ export default createPrompt((config, done) => { .map((choice) => { const line = ` ${choice.key}) ${getChoiceKey(choice, 'name')}`; if (choice.key === value.toLowerCase()) { - return chalk.cyan(line); + return theme.style.highlight(line); } return line; @@ -117,7 +121,7 @@ export default createPrompt((config, done) => { let error = ''; if (errorMsg) { - error = chalk.red(`> ${errorMsg}`); + error = theme.style.error(errorMsg); } return [ diff --git a/packages/input/README.md b/packages/input/README.md index 0bf1b44d2..21ee78efa 100644 --- a/packages/input/README.md +++ b/packages/input/README.md @@ -28,6 +28,27 @@ const answer = await input({ message: 'Enter your name' }); | default | `string` | no | Default value if no answer is provided (clear it by pressing backspace) | | transformer | `(string, { isFinal: boolean }) => string` | no | Transform/Format the raw value entered by the user. Once the prompt is completed, `isFinal` will be `true`. This function is purely visual, modify the answer in your code if needed. | | validate | `string => boolean \| string \| Promise` | no | On submit, validate the filtered answered content. When returning a string, it'll be used as the error message displayed to the user. Note: returning a rejected promise, we'll assume a code error happened and crash. | +| theme | [See Theming](#Theming) | no | Customize look of the prompt. | + +## Theming + +You can theme a prompt by passing a `theme` object option. The theme object only need to includes the keys you wish to modify, we'll fallback on the defaults for the rest. + +```ts +type Theme = { + prefix: string; + spinner: { + interval: number; + frames: string[]; + }; + style: { + answer: (text: string) => string; + message: (text: string) => string; + error: (text: string) => string; + defaultAnswer: (text: string) => string; + }; +}; +``` # License diff --git a/packages/input/input.test.mts b/packages/input/input.test.mts index 5b56f6dd5..7e761048b 100644 --- a/packages/input/input.test.mts +++ b/packages/input/input.test.mts @@ -153,4 +153,38 @@ describe('input prompt', () => { await expect(answer).resolves.toEqual('Mikey'); expect(getScreen()).toMatchInlineSnapshot(`"? What is your name Mikey"`); }); + + it('is theme-able', async () => { + const { answer, events, getScreen } = await render(input, { + message: 'Answer must be: 2', + validate: (value) => value === '2', + theme: { + prefix: 'Q:', + style: { + message: (text) => `${text} ===`, + error: (text) => `!! ${text} !!`, + answer: (text) => `_${text}_`, + }, + }, + }); + + expect(getScreen()).toMatchInlineSnapshot(`"Q: Answer must be: 2 ==="`); + + events.type('1'); + expect(getScreen()).toMatchInlineSnapshot(`"Q: Answer must be: 2 === 1"`); + + events.keypress('enter'); + await Promise.resolve(); + expect(getScreen()).toMatchInlineSnapshot(` + "Q: Answer must be: 2 === 1 + !! You must provide a valid value !!" + `); + + events.keypress('backspace'); + events.type('2'); + events.keypress('enter'); + await expect(answer).resolves.toEqual('2'); + + expect(getScreen()).toMatchInlineSnapshot(`"Q: Answer must be: 2 === _2_"`); + }); }); diff --git a/packages/input/package.json b/packages/input/package.json index 60b2e74cc..fa9e18ea2 100644 --- a/packages/input/package.json +++ b/packages/input/package.json @@ -55,8 +55,7 @@ "homepage": "https://github.com/SBoudrias/Inquirer.js/blob/master/packages/input/README.md", "dependencies": { "@inquirer/core": "^6.0.0", - "@inquirer/type": "^1.1.6", - "chalk": "^4.1.2" + "@inquirer/type": "^1.1.6" }, "devDependencies": { "@inquirer/testing": "^2.1.10" diff --git a/packages/input/src/index.mts b/packages/input/src/index.mts index 808aeb980..aa30bc06a 100644 --- a/packages/input/src/index.mts +++ b/packages/input/src/index.mts @@ -5,19 +5,22 @@ import { usePrefix, isEnterKey, isBackspaceKey, + makeTheme, + type Theme, } from '@inquirer/core'; -import type {} from '@inquirer/type'; -import chalk from 'chalk'; +import type { PartialDeep } from '@inquirer/type'; type InputConfig = { message: string; default?: string; transformer?: (value: string, { isFinal }: { isFinal: boolean }) => string; validate?: (value: string) => boolean | string | Promise; + theme?: PartialDeep; }; export default createPrompt((config, done) => { const { validate = () => true } = config; + const theme = makeTheme(config.theme); const [status, setStatus] = useState('pending'); const [defaultValue = '', setDefaultValue] = useState( config.default, @@ -26,7 +29,7 @@ export default createPrompt((config, done) => { const [value, setValue] = useState(''); const isLoading = status === 'loading'; - const prefix = usePrefix(isLoading); + const prefix = usePrefix({ isLoading, theme }); useKeypress(async (key, rl) => { // Ignore keypress while our prompt is doing other processing. @@ -62,22 +65,22 @@ export default createPrompt((config, done) => { } }); - const message = chalk.bold(config.message); + const message = theme.style.message(config.message); let formattedValue = value; if (typeof config.transformer === 'function') { formattedValue = config.transformer(value, { isFinal: status === 'done' }); } else if (status === 'done') { - formattedValue = chalk.cyan(value); + formattedValue = theme.style.answer(value); } let defaultStr; if (defaultValue && status !== 'done' && !value) { - defaultStr = chalk.dim(`(${defaultValue})`); + defaultStr = theme.style.defaultAnswer(defaultValue); } let error = ''; if (errorMsg) { - error = chalk.red(`> ${errorMsg}`); + error = theme.style.error(errorMsg); } return [[prefix, message, defaultStr, formattedValue].filter(Boolean).join(' '), error]; diff --git a/packages/password/README.md b/packages/password/README.md index d06446879..13c12c9aa 100644 --- a/packages/password/README.md +++ b/packages/password/README.md @@ -27,6 +27,27 @@ const answer = await password({ message: 'Enter your name' }); | message | `string` | yes | The question to ask | | mask | `boolean` | no | Show a `*` mask over the input or keep it transparent | | validate | `string => boolean \| string \| Promise` | no | On submit, validate the filtered answered content. When returning a string, it'll be used as the error message displayed to the user. Note: returning a rejected promise, we'll assume a code error happened and crash. | +| theme | [See Theming](#Theming) | no | Customize look of the prompt. | + +## Theming + +You can theme a prompt by passing a `theme` object option. The theme object only need to includes the keys you wish to modify, we'll fallback on the defaults for the rest. + +```ts +type Theme = { + prefix: string; + spinner: { + interval: number; + frames: string[]; + }; + style: { + answer: (text: string) => string; + message: (text: string) => string; + error: (text: string) => string; + help: (text: string) => string; + }; +}; +``` # License diff --git a/packages/password/package.json b/packages/password/package.json index 527659170..df286826e 100644 --- a/packages/password/package.json +++ b/packages/password/package.json @@ -59,8 +59,7 @@ "dependencies": { "@inquirer/core": "^6.0.0", "@inquirer/type": "^1.1.6", - "ansi-escapes": "^4.3.2", - "chalk": "^4.1.2" + "ansi-escapes": "^4.3.2" }, "devDependencies": { "@inquirer/testing": "^2.1.10" diff --git a/packages/password/src/index.mts b/packages/password/src/index.mts index 03d768fde..b3434a573 100644 --- a/packages/password/src/index.mts +++ b/packages/password/src/index.mts @@ -4,24 +4,29 @@ import { useKeypress, usePrefix, isEnterKey, + makeTheme, + type Theme, } from '@inquirer/core'; -import chalk from 'chalk'; import ansiEscapes from 'ansi-escapes'; +import type { PartialDeep } from '@inquirer/type'; type PasswordConfig = { message: string; mask?: boolean | string; validate?: (value: string) => boolean | string | Promise; + theme?: PartialDeep; }; export default createPrompt((config, done) => { const { validate = () => true } = config; + const theme = makeTheme(config.theme); + const [status, setStatus] = useState('pending'); const [errorMsg, setError] = useState(undefined); const [value, setValue] = useState(''); const isLoading = status === 'loading'; - const prefix = usePrefix(isLoading); + const prefix = usePrefix({ isLoading, theme }); useKeypress(async (key, rl) => { // Ignore keypress while our prompt is doing other processing. @@ -50,7 +55,7 @@ export default createPrompt((config, done) => { } }); - const message = chalk.bold(config.message); + const message = theme.style.message(config.message); let formattedValue = ''; let helpTip; @@ -58,16 +63,16 @@ export default createPrompt((config, done) => { const maskChar = typeof config.mask === 'string' ? config.mask : '*'; formattedValue = maskChar.repeat(value.length); } else if (status !== 'done') { - helpTip = `${chalk.dim('[input is masked]')}${ansiEscapes.cursorHide}`; + helpTip = `${theme.style.help('[input is masked]')}${ansiEscapes.cursorHide}`; } if (status === 'done') { - formattedValue = chalk.cyan(formattedValue); + formattedValue = theme.style.answer(formattedValue); } let error = ''; if (errorMsg) { - error = chalk.red(`> ${errorMsg}`); + error = theme.style.error(errorMsg); } return [[prefix, message, formattedValue, helpTip].filter(Boolean).join(' '), error]; diff --git a/packages/rawlist/README.md b/packages/rawlist/README.md index 4a2869294..a227595e7 100644 --- a/packages/rawlist/README.md +++ b/packages/rawlist/README.md @@ -33,6 +33,27 @@ const answer = await rawlist({ | -------- | ------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | message | `string` | yes | The question to ask | | choices | `Array<{ value: string, name?: string, key?: string }>` | yes | List of the available choices. The `value` will be returned as the answer, and used as display if no `name` is defined. By default, choices will be selected by index. This can be customized by using the `key` option. | +| theme | [See Theming](#Theming) | no | Customize look of the prompt. | + +## Theming + +You can theme a prompt by passing a `theme` object option. The theme object only need to includes the keys you wish to modify, we'll fallback on the defaults for the rest. + +```ts +type Theme = { + prefix: string; + spinner: { + interval: number; + frames: string[]; + }; + style: { + answer: (text: string) => string; + message: (text: string) => string; + error: (text: string) => string; + highlight: (text: string) => string; + }; +}; +``` # License diff --git a/packages/rawlist/src/index.mts b/packages/rawlist/src/index.mts index 606e130c9..31472910f 100644 --- a/packages/rawlist/src/index.mts +++ b/packages/rawlist/src/index.mts @@ -5,8 +5,10 @@ import { usePrefix, isEnterKey, Separator, + makeTheme, + type Theme, } from '@inquirer/core'; -import type {} from '@inquirer/type'; +import type { PartialDeep } from '@inquirer/type'; import chalk from 'chalk'; const numberRegex = /[0-9]+/; @@ -20,6 +22,7 @@ type Choice = { type RawlistConfig = { message: string; choices: ReadonlyArray | Separator>; + theme?: PartialDeep; }; function isSelectableChoice( @@ -34,7 +37,8 @@ export default createPrompt( const [status, setStatus] = useState('pending'); const [value, setValue] = useState(''); const [errorMsg, setError] = useState(undefined); - const prefix = usePrefix(); + const theme = makeTheme(config.theme); + const prefix = usePrefix({ theme }); useKeypress((key, rl) => { if (isEnterKey(key)) { @@ -64,10 +68,10 @@ export default createPrompt( } }); - const message = chalk.bold(config.message); + const message = theme.style.message(config.message); if (status === 'done') { - return `${prefix} ${message} ${chalk.cyan(value)}`; + return `${prefix} ${message} ${theme.style.answer(value)}`; } let index = 0; @@ -81,7 +85,7 @@ export default createPrompt( const line = ` ${choice.key || index}) ${choice.name || choice.value}`; if (choice.key === value.toLowerCase() || String(index) === value) { - return chalk.cyan(line); + return theme.style.highlight(line); } return line; @@ -90,7 +94,7 @@ export default createPrompt( let error = ''; if (errorMsg) { - error = chalk.red(`> ${errorMsg}`); + error = theme.style.error(errorMsg); } return [ diff --git a/packages/select/README.md b/packages/select/README.md index bd093635c..b237f8876 100644 --- a/packages/select/README.md +++ b/packages/select/README.md @@ -54,9 +54,35 @@ const answer = await select({ | default | `string` | no | Defines in front of which item the cursor will initially appear. When omitted, the cursor will appear on the first selectable item. | | pageSize | `number` | no | By default, lists of choice longer than 7 will be paginated. Use this option to control how many choices will appear on the screen at once. | | loop | `boolean` | no | Defaults to `true`. When set to `false`, the cursor will be constrained to the top and bottom of the choice list without looping. | +| theme | [See Theming](#Theming) | no | Customize look of the prompt. | The `Separator` object can be used to render non-selectable lines in the choice list. By default it'll render a line, but you can provide the text as argument (`new Separator('-- Dependencies --')`). This option is often used to add labels to groups within long list of options. +## Theming + +You can theme a prompt by passing a `theme` object option. The theme object only need to includes the keys you wish to modify, we'll fallback on the defaults for the rest. + +```ts +type Theme = { + prefix: string; + spinner: { + interval: number; + frames: string[]; + }; + style: { + answer: (text: string) => string; + message: (text: string) => string; + error: (text: string) => string; + help: (text: string) => string; + highlight: (text: string) => string; + disabled: (text: string) => string; + }; + icon: { + cursor: string; + }; +}; +``` + # License Copyright (c) 2023 Simon Boudrias (twitter: [@vaxilart](https://twitter.com/Vaxilart))
diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index de9713c5a..28f3b1ac1 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -11,12 +11,24 @@ import { isDownKey, isNumberKey, Separator, + makeTheme, + type Theme, } from '@inquirer/core'; -import type {} from '@inquirer/type'; +import type { PartialDeep } from '@inquirer/type'; import chalk from 'chalk'; import figures from 'figures'; import ansiEscapes from 'ansi-escapes'; +type SelectTheme = { + icon: { cursor: string }; + style: { disabled: (text: string) => string }; +}; + +const selectTheme: SelectTheme = { + icon: { cursor: figures.pointer }, + style: { disabled: (text: string) => chalk.dim(`- ${text}`) }, +}; + type Choice = { value: Value; name?: string; @@ -31,6 +43,7 @@ type SelectConfig = { pageSize?: number; loop?: boolean; default?: unknown; + theme?: PartialDeep>; }; type Item = Separator | Choice; @@ -39,28 +52,12 @@ function isSelectable(item: Item): item is Choice { return !Separator.isSeparator(item) && !item.disabled; } -function renderItem({ item, isActive }: { item: Item; isActive: boolean }) { - if (Separator.isSeparator(item)) { - return ` ${item.separator}`; - } - - const line = item.name || item.value; - if (item.disabled) { - const disabledLabel = - typeof item.disabled === 'string' ? item.disabled : '(disabled)'; - return chalk.dim(`- ${line} ${disabledLabel}`); - } - - const color = isActive ? chalk.cyan : (x: string) => x; - const prefix = isActive ? figures.pointer : ` `; - return color(`${prefix} ${line}`); -} - export default createPrompt( (config: SelectConfig, done: (value: Value) => void): string => { const { choices: items, loop = true, pageSize = 7 } = config; const firstRender = useRef(true); - const prefix = usePrefix(); + const theme = makeTheme(selectTheme, config.theme); + const prefix = usePrefix({ theme }); const [status, setStatus] = useState('pending'); const bounds = useMemo(() => { @@ -114,26 +111,44 @@ export default createPrompt( } }); - const message = chalk.bold(config.message); + const message = theme.style.message(config.message); let helpTip; if (firstRender.current && items.length <= pageSize) { firstRender.current = false; - helpTip = chalk.dim('(Use arrow keys)'); + helpTip = theme.style.help('(Use arrow keys)'); } const page = usePagination>({ items, active, - renderItem, + renderItem({ item, isActive }: { item: Item; isActive: boolean }) { + if (Separator.isSeparator(item)) { + return ` ${item.separator}`; + } + + const line = item.name || item.value; + if (item.disabled) { + const disabledLabel = + typeof item.disabled === 'string' ? item.disabled : '(disabled)'; + return theme.style.disabled(`${line} ${disabledLabel}`); + } + + const color = isActive ? theme.style.highlight : (x: string) => x; + const cursor = isActive ? theme.icon.cursor : ` `; + return color(`${cursor} ${line}`); + }, pageSize, loop, + theme, }); if (status === 'done') { - return `${prefix} ${message} ${chalk.cyan( - selectedChoice.name || selectedChoice.value, - )}`; + const answer = + selectedChoice.name || + // TODO: Could we enforce that at the type level? Name should be defined for non-string values. + String(selectedChoice.value); + return `${prefix} ${message} ${theme.style.answer(answer)}`; } const choiceDescription = selectedChoice.description diff --git a/yarn.lock b/yarn.lock index ff169d578..8d7b45c94 100644 --- a/yarn.lock +++ b/yarn.lock @@ -373,7 +373,6 @@ __metadata: "@inquirer/core": ^6.0.0 "@inquirer/testing": ^2.1.10 "@inquirer/type": ^1.1.6 - chalk: ^4.1.2 languageName: unknown linkType: soft @@ -416,7 +415,6 @@ __metadata: dependencies: "@inquirer/core": ^6.0.0 "@inquirer/type": ^1.1.6 - chalk: ^4.1.2 external-editor: ^3.1.0 languageName: unknown linkType: soft @@ -448,7 +446,6 @@ __metadata: "@inquirer/core": ^6.0.0 "@inquirer/testing": ^2.1.10 "@inquirer/type": ^1.1.6 - chalk: ^4.1.2 languageName: unknown linkType: soft @@ -460,7 +457,6 @@ __metadata: "@inquirer/testing": ^2.1.10 "@inquirer/type": ^1.1.6 ansi-escapes: ^4.3.2 - chalk: ^4.1.2 languageName: unknown linkType: soft