index.js 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193
  1. import { createPrompt, useState, useKeypress, usePrefix, usePagination, useRef, useMemo, makeTheme, isUpKey, isDownKey, isSpaceKey, isNumberKey, isEnterKey, ValidationError, Separator, } from '@inquirer/core';
  2. import colors from 'yoctocolors-cjs';
  3. import figures from '@inquirer/figures';
  4. import ansiEscapes from 'ansi-escapes';
  5. const checkboxTheme = {
  6. icon: {
  7. checked: colors.green(figures.circleFilled),
  8. unchecked: figures.circle,
  9. cursor: figures.pointer,
  10. },
  11. style: {
  12. disabledChoice: (text) => colors.dim(`- ${text}`),
  13. renderSelectedChoices: (selectedChoices) => selectedChoices.map((choice) => choice.short).join(', '),
  14. description: (text) => colors.cyan(text),
  15. },
  16. helpMode: 'auto',
  17. };
  18. function isSelectable(item) {
  19. return !Separator.isSeparator(item) && !item.disabled;
  20. }
  21. function isChecked(item) {
  22. return isSelectable(item) && Boolean(item.checked);
  23. }
  24. function toggle(item) {
  25. return isSelectable(item) ? { ...item, checked: !item.checked } : item;
  26. }
  27. function check(checked) {
  28. return function (item) {
  29. return isSelectable(item) ? { ...item, checked } : item;
  30. };
  31. }
  32. function normalizeChoices(choices) {
  33. return choices.map((choice) => {
  34. if (Separator.isSeparator(choice))
  35. return choice;
  36. if (typeof choice === 'string') {
  37. return {
  38. value: choice,
  39. name: choice,
  40. short: choice,
  41. disabled: false,
  42. checked: false,
  43. };
  44. }
  45. const name = choice.name ?? String(choice.value);
  46. const normalizedChoice = {
  47. value: choice.value,
  48. name,
  49. short: choice.short ?? name,
  50. disabled: choice.disabled ?? false,
  51. checked: choice.checked ?? false,
  52. };
  53. if (choice.description) {
  54. normalizedChoice.description = choice.description;
  55. }
  56. return normalizedChoice;
  57. });
  58. }
  59. export default createPrompt((config, done) => {
  60. const { instructions, pageSize = 7, loop = true, required, validate = () => true, } = config;
  61. const shortcuts = { all: 'a', invert: 'i', ...config.shortcuts };
  62. const theme = makeTheme(checkboxTheme, config.theme);
  63. const firstRender = useRef(true);
  64. const [status, setStatus] = useState('idle');
  65. const prefix = usePrefix({ status, theme });
  66. const [items, setItems] = useState(normalizeChoices(config.choices));
  67. const bounds = useMemo(() => {
  68. const first = items.findIndex(isSelectable);
  69. const last = items.findLastIndex(isSelectable);
  70. if (first === -1) {
  71. throw new ValidationError('[checkbox prompt] No selectable choices. All choices are disabled.');
  72. }
  73. return { first, last };
  74. }, [items]);
  75. const [active, setActive] = useState(bounds.first);
  76. const [showHelpTip, setShowHelpTip] = useState(true);
  77. const [errorMsg, setError] = useState();
  78. useKeypress(async (key) => {
  79. if (isEnterKey(key)) {
  80. const selection = items.filter(isChecked);
  81. const isValid = await validate([...selection]);
  82. if (required && !items.some(isChecked)) {
  83. setError('At least one choice must be selected');
  84. }
  85. else if (isValid === true) {
  86. setStatus('done');
  87. done(selection.map((choice) => choice.value));
  88. }
  89. else {
  90. setError(isValid || 'You must select a valid value');
  91. }
  92. }
  93. else if (isUpKey(key) || isDownKey(key)) {
  94. if (loop ||
  95. (isUpKey(key) && active !== bounds.first) ||
  96. (isDownKey(key) && active !== bounds.last)) {
  97. const offset = isUpKey(key) ? -1 : 1;
  98. let next = active;
  99. do {
  100. next = (next + offset + items.length) % items.length;
  101. } while (!isSelectable(items[next]));
  102. setActive(next);
  103. }
  104. }
  105. else if (isSpaceKey(key)) {
  106. setError(undefined);
  107. setShowHelpTip(false);
  108. setItems(items.map((choice, i) => (i === active ? toggle(choice) : choice)));
  109. }
  110. else if (key.name === shortcuts.all) {
  111. const selectAll = items.some((choice) => isSelectable(choice) && !choice.checked);
  112. setItems(items.map(check(selectAll)));
  113. }
  114. else if (key.name === shortcuts.invert) {
  115. setItems(items.map(toggle));
  116. }
  117. else if (isNumberKey(key)) {
  118. // Adjust index to start at 1
  119. const position = Number(key.name) - 1;
  120. const item = items[position];
  121. if (item != null && isSelectable(item)) {
  122. setActive(position);
  123. setItems(items.map((choice, i) => (i === position ? toggle(choice) : choice)));
  124. }
  125. }
  126. });
  127. const message = theme.style.message(config.message, status);
  128. let description;
  129. const page = usePagination({
  130. items,
  131. active,
  132. renderItem({ item, isActive }) {
  133. if (Separator.isSeparator(item)) {
  134. return ` ${item.separator}`;
  135. }
  136. if (item.disabled) {
  137. const disabledLabel = typeof item.disabled === 'string' ? item.disabled : '(disabled)';
  138. return theme.style.disabledChoice(`${item.name} ${disabledLabel}`);
  139. }
  140. if (isActive) {
  141. description = item.description;
  142. }
  143. const checkbox = item.checked ? theme.icon.checked : theme.icon.unchecked;
  144. const color = isActive ? theme.style.highlight : (x) => x;
  145. const cursor = isActive ? theme.icon.cursor : ' ';
  146. return color(`${cursor}${checkbox} ${item.name}`);
  147. },
  148. pageSize,
  149. loop,
  150. });
  151. if (status === 'done') {
  152. const selection = items.filter(isChecked);
  153. const answer = theme.style.answer(theme.style.renderSelectedChoices(selection, items));
  154. return `${prefix} ${message} ${answer}`;
  155. }
  156. let helpTipTop = '';
  157. let helpTipBottom = '';
  158. if (theme.helpMode === 'always' ||
  159. (theme.helpMode === 'auto' &&
  160. showHelpTip &&
  161. (instructions === undefined || instructions))) {
  162. if (typeof instructions === 'string') {
  163. helpTipTop = instructions;
  164. }
  165. else {
  166. const keys = [
  167. `${theme.style.key('space')} to select`,
  168. shortcuts.all ? `${theme.style.key(shortcuts.all)} to toggle all` : '',
  169. shortcuts.invert
  170. ? `${theme.style.key(shortcuts.invert)} to invert selection`
  171. : '',
  172. `and ${theme.style.key('enter')} to proceed`,
  173. ];
  174. helpTipTop = ` (Press ${keys.filter((key) => key !== '').join(', ')})`;
  175. }
  176. if (items.length > pageSize &&
  177. (theme.helpMode === 'always' ||
  178. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  179. (theme.helpMode === 'auto' && firstRender.current))) {
  180. helpTipBottom = `\n${theme.style.help('(Use arrow keys to reveal more choices)')}`;
  181. firstRender.current = false;
  182. }
  183. }
  184. const choiceDescription = description
  185. ? `\n${theme.style.description(description)}`
  186. : ``;
  187. let error = '';
  188. if (errorMsg) {
  189. error = `\n${theme.style.error(errorMsg)}`;
  190. }
  191. return `${prefix} ${message}${helpTipTop}\n${page}${helpTipBottom}${choiceDescription}${error}${ansiEscapes.cursorHide}`;
  192. });
  193. export { Separator } from '@inquirer/core';