Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[major] Introduce theming capacities #1323

Merged
merged 2 commits into from
Feb 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@
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 @@
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 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 All @@ -94,7 +102,7 @@

const bounds = useMemo(() => {
const first = items.findIndex(isSelectable);
// TODO: Replace with `findLastIndex` when it's available.

Check warning on line 105 in packages/checkbox/src/index.mts

View workflow job for this annotation

GitHub Actions / Linting

Unexpected 'todo' comment: 'TODO: Replace with `findLastIndex` when...'

Check warning on line 105 in packages/checkbox/src/index.mts

View workflow job for this annotation

GitHub Actions / Linting

Unexpected 'todo' comment: 'TODO: Replace with `findLastIndex` when...'
const last = items.length - 1 - [...items].reverse().findIndex(isSelectable);

if (first < 0) {
Expand All @@ -110,7 +118,7 @@
const [showHelpTip, setShowHelpTip] = useState(true);
const [errorMsg, setError] = useState<string | undefined>(undefined);

useKeypress(async (key) => {

Check warning on line 121 in packages/checkbox/src/index.mts

View workflow job for this annotation

GitHub Actions / Linting

Async arrow function has a complexity of 21. Maximum allowed is 20

Check warning on line 121 in packages/checkbox/src/index.mts

View workflow job for this annotation

GitHub Actions / Linting

Async arrow function has a complexity of 21. Maximum allowed is 20
if (isEnterKey(key)) {
const selection = items.filter(isChecked);
const isValid = await validate([...selection]);
Expand Down Expand Up @@ -157,21 +165,38 @@
}
});

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 @@
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
Loading