Skip to content

Commit

Permalink
Feat: Apply theming to all prompts
Browse files Browse the repository at this point in the history
  • Loading branch information
SBoudrias committed Jan 28, 2024
1 parent e3eefcb commit 0e24047
Show file tree
Hide file tree
Showing 22 changed files with 385 additions and 112 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": "^5.1.2",
"@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}`;
});
21 changes: 21 additions & 0 deletions packages/editor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,27 @@ const answer = await editor({
| validate | `string => boolean \| string \| Promise<string \| boolean>` | 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

Expand Down
1 change: 0 additions & 1 deletion packages/editor/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@
"dependencies": {
"@inquirer/core": "^5.1.2",
"@inquirer/type": "^1.1.6",
"chalk": "^4.1.2",
"external-editor": "^3.1.0"
},
"scripts": {
Expand Down
26 changes: 15 additions & 11 deletions packages/editor/src/index.mts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import chalk from 'chalk';
import { editAsync } from 'external-editor';
import {
createPrompt,
Expand All @@ -7,24 +6,32 @@ import {
useKeypress,
usePrefix,
isEnterKey,
makeTheme,

Check warning on line 9 in packages/editor/src/index.mts

View check run for this annotation

Codecov / codecov/patch

packages/editor/src/index.mts#L9

Added line #L9 was not covered by tests
type InquirerReadline,
type Theme,

Check warning on line 11 in packages/editor/src/index.mts

View check run for this annotation

Codecov / codecov/patch

packages/editor/src/index.mts#L11

Added line #L11 was not covered by tests
} from '@inquirer/core';
import type {} from '@inquirer/type';
import type { PartialDeep } from '@inquirer/type';

Check warning on line 13 in packages/editor/src/index.mts

View check run for this annotation

Codecov / codecov/patch

packages/editor/src/index.mts#L13

Added line #L13 was not covered by tests

type EditorConfig = {
message: string;

Check warning on line 16 in packages/editor/src/index.mts

View check run for this annotation

Codecov / codecov/patch

packages/editor/src/index.mts#L15-L16

Added lines #L15 - L16 were not covered by tests
default?: string;
postfix?: string;
waitForUseInput?: boolean;
validate?: (value: string) => boolean | string | Promise<string | boolean>;
theme?: PartialDeep<Theme>;
};

Check warning on line 22 in packages/editor/src/index.mts

View check run for this annotation

Codecov / codecov/patch

packages/editor/src/index.mts#L21-L22

Added lines #L21 - L22 were not covered by tests

export default createPrompt<string, EditorConfig>((config, done) => {
const { waitForUseInput = true, validate = () => true } = config;
const theme = makeTheme(config.theme);

Check warning on line 27 in packages/editor/src/index.mts

View check run for this annotation

Codecov / codecov/patch

packages/editor/src/index.mts#L26-L27

Added lines #L26 - L27 were not covered by tests
const [status, setStatus] = useState<string>('pending');
const [value, setValue] = useState<string>(config.default || '');
const [errorMsg, setError] = useState<string | undefined>(undefined);

const isLoading = status === 'loading';
const prefix = usePrefix({ isLoading, theme });

Check warning on line 34 in packages/editor/src/index.mts

View check run for this annotation

Codecov / codecov/patch

packages/editor/src/index.mts#L32-L34

Added lines #L32 - L34 were not covered by tests
function startEditor(rl: InquirerReadline) {
rl.pause();
editAsync(
Expand Down Expand Up @@ -70,21 +77,18 @@ export default createPrompt<string, EditorConfig>((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 = '';

Check warning on line 81 in packages/editor/src/index.mts

View check run for this annotation

Codecov / codecov/patch

packages/editor/src/index.mts#L80-L81

Added lines #L80 - L81 were not covered by tests
if (status === 'loading') {
helpTip = chalk.dim('Received');
helpTip = theme.style.help('Received');

Check warning on line 83 in packages/editor/src/index.mts

View check run for this annotation

Codecov / codecov/patch

packages/editor/src/index.mts#L83

Added line #L83 was not covered by tests
} else if (status === 'pending') {
helpTip = chalk.dim('Press <enter> to launch your preferred editor.');
const enterKey = theme.style.key('enter');
helpTip = theme.style.help(`Press ${enterKey} to launch your preferred editor.`);

Check warning on line 86 in packages/editor/src/index.mts

View check run for this annotation

Codecov / codecov/patch

packages/editor/src/index.mts#L85-L86

Added lines #L85 - L86 were not covered by tests
}

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

Check warning on line 91 in packages/editor/src/index.mts

View check run for this annotation

Codecov / codecov/patch

packages/editor/src/index.mts#L91

Added line #L91 was not covered by tests
}

return [[prefix, message, helpTip].filter(Boolean).join(' '), error];
Expand Down
Loading

0 comments on commit 0e24047

Please sign in to comment.