Skip to content

Commit

Permalink
[major] Introduce theming capacities (#1323)
Browse files Browse the repository at this point in the history
* Chore: Refactor usePrefix to rely on a new Theme construct

* Feat: Apply theming to all prompts
  • Loading branch information
SBoudrias authored Feb 4, 2024
1 parent 6dda345 commit 88daff9
Show file tree
Hide file tree
Showing 29 changed files with 532 additions and 123 deletions.
30 changes: 30 additions & 0 deletions packages/checkbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<string \| boolean>` | 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))<br/>
Expand Down
81 changes: 53 additions & 28 deletions packages/checkbox/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value> = {
name?: string;
value: Value;
Expand All @@ -36,6 +60,7 @@ type Config<Value> = {
validate?: (
items: ReadonlyArray<Item<Value>>,
) => boolean | string | Promise<string | boolean>;
theme?: PartialDeep<Theme<CheckboxTheme>>;
};

type Item<Value> = Separator | Choice<Value>;
Expand All @@ -58,35 +83,18 @@ function check(checked: boolean) {
};
}

function renderItem<Value>({ item, isActive }: { item: Item<Value>; 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(
<Value extends unknown>(config: Config<Value>, done: (value: Array<Value>) => void) => {
const {
prefix = usePrefix(),
instructions,
pageSize = 7,
loop = true,
choices,
required,
validate = () => true,
} = config;
const theme = makeTheme<CheckboxTheme>(checkboxTheme, config.theme);
const prefix = usePrefix({ theme });
const [status, setStatus] = useState('pending');
const [items, setItems] = useState<ReadonlyArray<Item<Value>>>(
choices.map((choice) => ({ ...choice })),
Expand Down Expand Up @@ -157,21 +165,38 @@ export default createPrompt(
}
});

const message = chalk.bold(config.message);
const message = theme.style.message(config.message);

const page = usePagination<Item<Value>>({
items,
active,
renderItem,
renderItem({ item, isActive }: { item: Item<Value>; 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 = '';
Expand All @@ -180,18 +205,18 @@ export default createPrompt(
helpTip = instructions;
} else {
const keys = [
`${chalk.cyan.bold('<space>')} to select`,
`${chalk.cyan.bold('<a>')} to toggle all`,
`${chalk.cyan.bold('<i>')} to invert selection`,
`and ${chalk.cyan.bold('<enter>')} 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(', ')})`;
}
}

let error = '';
if (errorMsg) {
error = chalk.red(`> ${errorMsg}`);
error = theme.style.error(errorMsg);
}

return `${prefix} ${message}${helpTip}\n${page}\n${error}${ansiEscapes.cursorHide}`;
Expand Down
30 changes: 25 additions & 5 deletions packages/confirm/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 1 addition & 2 deletions packages/confirm/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
17 changes: 11 additions & 6 deletions packages/confirm/src/index.mts
Original file line number Diff line number Diff line change
@@ -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<Theme>;
};

export default createPrompt<boolean, ConfirmConfig>((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)) {
Expand All @@ -37,11 +40,13 @@ export default createPrompt<boolean, ConfirmConfig>((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}`;
});
72 changes: 71 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const confirm = createPrompt<boolean, { message: string; default?: boolean }>(
(config, done) => {
const [status, setStatus] = useState('pending');
const [value, setValue] = useState('');
const prefix = usePrefix();
const prefix = usePrefix({});

useKeypress((key, rl) => {
if (isEnterKey(key)) {
Expand Down Expand Up @@ -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<Theme>;
};

export default createPrompt<string, PromptConfig>((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<Theme<PromptTheme>>;
};

export default createPrompt<string, PromptConfig>((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))<br/>
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/index.mts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading

0 comments on commit 88daff9

Please sign in to comment.