diff --git a/packages/checkbox/checkbox.test.mts b/packages/checkbox/checkbox.test.mts index 4390c33fb..d0818e429 100644 --- a/packages/checkbox/checkbox.test.mts +++ b/packages/checkbox/checkbox.test.mts @@ -59,6 +59,168 @@ describe('checkbox prompt', () => { await expect(answer).resolves.toEqual([2, 3]); }); + it('does not scroll up beyond first item when not looping', async () => { + const { answer, events, getScreen } = await render(checkbox, { + message: 'Select a number', + choices: numberedChoices, + loop: false, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Press to select, to toggle all, to invert + selection, and to proceed) + ❯◯ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('up'); + events.keypress('space'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + ❯◉ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"'); + + await expect(answer).resolves.toEqual([1]); + }); + + it('does not scroll up beyond first selectable item when not looping', async () => { + const { answer, events, getScreen } = await render(checkbox, { + message: 'Select a number', + choices: [new Separator(), ...numberedChoices], + loop: false, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Press to select, to toggle all, to invert + selection, and to proceed) + ────────────── + ❯◯ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('up'); + events.keypress('space'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + ────────────── + ❯◉ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + expect(getScreen()).toMatchInlineSnapshot('"? Select a number 1"'); + + await expect(answer).resolves.toEqual([1]); + }); + + it('does not scroll down beyond last option when not looping', async () => { + const { answer, events, getScreen } = await render(checkbox, { + message: 'Select a number', + choices: numberedChoices, + loop: false, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Press to select, to toggle all, to invert + selection, and to proceed) + ❯◯ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices)" + `); + + numberedChoices.forEach(() => events.keypress('down')); + events.keypress('down'); + events.keypress('space'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + ◯ 6 + ◯ 7 + ◯ 8 + ◯ 9 + ◯ 10 + ◯ 11 + ❯◉ 12 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + expect(getScreen()).toMatchInlineSnapshot('"? Select a number 12"'); + + await expect(answer).resolves.toEqual([12]); + }); + + it('does not scroll down beyond last selectable option when not looping', async () => { + const { answer, events, getScreen } = await render(checkbox, { + message: 'Select a number', + choices: [...numberedChoices, new Separator()], + loop: false, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Press to select, to toggle all, to invert + selection, and to proceed) + ❯◯ 1 + ◯ 2 + ◯ 3 + ◯ 4 + ◯ 5 + ◯ 6 + ◯ 7 + (Use arrow keys to reveal more choices)" + `); + + numberedChoices.forEach(() => events.keypress('down')); + events.keypress('down'); + events.keypress('space'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + ◯ 7 + ◯ 8 + ◯ 9 + ◯ 10 + ◯ 11 + ❯◉ 12 + ────────────── + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + expect(getScreen()).toMatchInlineSnapshot('"? Select a number 12"'); + + await expect(answer).resolves.toEqual([12]); + }); + it('use number key to select an option', async () => { const { answer, events, getScreen } = await render(checkbox, { message: 'Select a number', diff --git a/packages/checkbox/src/index.mts b/packages/checkbox/src/index.mts index d471922c7..b222182d9 100644 --- a/packages/checkbox/src/index.mts +++ b/packages/checkbox/src/index.mts @@ -4,6 +4,7 @@ import { useKeypress, usePrefix, usePagination, + useMemo, isUpKey, isDownKey, isSpaceKey, @@ -30,6 +31,7 @@ type Config = PromptConfig<{ pageSize?: number; instructions?: string | boolean; choices: ReadonlyArray | Separator>; + loop?: boolean; }>; type Item = Separator | Choice; @@ -72,19 +74,27 @@ function renderItem({ item, isActive }: { item: Item; isActive: bo export default createPrompt( (config: Config, done: (value: Array) => void) => { - const { prefix = usePrefix(), instructions, pageSize, choices } = config; + const { prefix = usePrefix(), instructions, pageSize, loop = true, choices } = config; const [status, setStatus] = useState('pending'); const [items, setItems] = useState>>( choices.map((choice) => ({ ...choice })), ); - const [active, setActive] = useState(() => { - const selected = items.findIndex(isSelectable); - if (selected < 0) + + const bounds = useMemo(() => { + const first = items.findIndex(isSelectable); + // TODO: Replace with `findLastIndex` when it's available. + const last = items.length - 1 - [...items].reverse().findIndex(isSelectable); + + if (first < 0) { throw new Error( '[checkbox prompt] No selectable choices. All choices are disabled.', ); - return selected; - }); + } + + return { first, last }; + }, [items]); + + const [active, setActive] = useState(bounds.first); const [showHelpTip, setShowHelpTip] = useState(true); useKeypress((key) => { @@ -92,6 +102,8 @@ export default createPrompt( setStatus('done'); done(items.filter(isChecked).map((choice) => choice.value)); } else if (isUpKey(key) || isDownKey(key)) { + if (!loop && active === bounds.first && isUpKey(key)) return; + if (!loop && active === bounds.last && isDownKey(key)) return; const offset = isUpKey(key) ? -1 : 1; let next = active; do { @@ -120,13 +132,12 @@ export default createPrompt( const message = chalk.bold(config.message); - const lines = items - .map((item, index) => renderItem({ item, isActive: index === active })) - .join('\n'); - - const page = usePagination(lines, { + const page = usePagination>({ + items, active, + renderItem, pageSize, + loop, }); if (status === 'done') { diff --git a/packages/core/README.md b/packages/core/README.md index b23d75854..d5f75e3fb 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -125,20 +125,25 @@ Listening for keypress events inside an inquirer prompt is a very common pattern ### `usePagination` -When looping through a long list of options (like in the `select` prompt), paginating the results appearing on the screen at once can be necessary. The `usePagination` hook is the utility used within the `select` and `checkbox` prompt to cycle through the list of options. +When looping through a long list of options (like in the `select` prompt), paginating the results appearing on the screen at once can be necessary. The `usePagination` hook is the utility used within the `select` and `checkbox` prompts to cycle through the list of options. + +Pagination works by taking in the list of options and returning a subset of the rendered items that fit within the page. The hook takes in a few options. It needs a list of options (`items`), and a `pageSize` which is the number of lines to be rendered. The `active` index is the index of the currently selected/selectable item. The `loop` option is a boolean that indicates if the list should loop around when reaching the end: this is the default behavior. The pagination hook renders items only as necessary, so it takes a function that can render an item at an index, including an `active` state, called `renderItem`. ```js export default createPrompt((config, done) => { - const [cursorPosition, setCursorPosition] = useState(0); + const [active, setActive] = useState(0); const allChoices = config.choices.map((choice) => choice.name); - const windowedChoices = usePagination(allChoices, { - active: cursorPosition, + const page = usePagination({ + items: allChoices, + active: active, + renderItem: ({ item, index, isActive }) => `${isActive ? ">" : " "}${index}. ${item.toString()}` pageSize: config.pageSize, + loop: config.loop, }); - return `... ${windowedChoices}`; + return `... ${page}`; }); ``` diff --git a/packages/core/src/index.mts b/packages/core/src/index.mts index ad6386019..20a51d7fd 100644 --- a/packages/core/src/index.mts +++ b/packages/core/src/index.mts @@ -5,7 +5,7 @@ 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 { usePagination } from './lib/use-pagination.mjs'; +export { usePagination } from './lib/pagination/use-pagination.mjs'; export { createPrompt, type PromptConfig, diff --git a/packages/core/src/lib/pagination/lines.mts b/packages/core/src/lib/pagination/lines.mts new file mode 100644 index 000000000..448527cbb --- /dev/null +++ b/packages/core/src/lib/pagination/lines.mts @@ -0,0 +1,94 @@ +import { type Prettify } from '@inquirer/type'; +import { breakLines } from '../utils.mjs'; + +/** Represents an item that's part of a layout, about to be rendered */ +export type Layout = { + item: T; + index: number; + isActive: boolean; +}; + +function split(content: string, width: number) { + return breakLines(content, width).split('\n'); +} + +/** + * Rotates an array of items by an integer number of positions. + * @param {number} count The number of positions to rotate by + * @param {T[]} items The items to rotate + */ +function rotate(count: number, items: readonly T[]): readonly T[] { + const max = items.length; + const offset = ((count % max) + max) % max; + return items.slice(offset).concat(items.slice(0, offset)); +} + +/** + * Renders a page of items as lines that fit within the given width ensuring + * that the number of lines is not greater than the page size, and the active + * item renders at the provided position, while prioritizing that as many lines + * of the active item get rendered as possible. + */ +export function lines({ + items, + width, + renderItem, + active, + position: requested, + pageSize, +}: { + items: readonly T[]; + /** The width of a rendered line in characters. */ + width: number; + /** Renders an item as part of a page. */ + renderItem: (layout: Prettify>) => string; + /** The index of the active item in the list of items. */ + active: number; + /** The position on the page at which to render the active item. */ + position: number; + /** The number of lines to render per page. */ + pageSize: number; +}): string[] { + const layouts = items.map>((item, index) => ({ + item, + index, + isActive: index === active, + })); + const layoutsInPage = rotate(active - requested, layouts).slice(0, pageSize); + const renderItemAt = (index: number) => split(renderItem(layoutsInPage[index]!), width); + + // Create a blank array of lines for the page + const pageBuffer = new Array(pageSize); + + // Render the active item to decide the position + const activeItem = renderItemAt(requested).slice(0, pageSize); + const position = + requested + activeItem.length <= pageSize ? requested : pageSize - activeItem.length; + + // Add the lines of the active item into the page + pageBuffer.splice(position, activeItem.length, ...activeItem); + + // Fill the page under the active item + let bufferPointer = position + activeItem.length; + let layoutPointer = requested + 1; + while (bufferPointer < pageSize && layoutPointer < layoutsInPage.length) { + for (const line of renderItemAt(layoutPointer)) { + pageBuffer[bufferPointer++] = line; + if (bufferPointer >= pageSize) break; + } + layoutPointer++; + } + + // Fill the page over the active item + bufferPointer = position - 1; + layoutPointer = requested - 1; + while (bufferPointer >= 0 && layoutPointer >= 0) { + for (const line of renderItemAt(layoutPointer).reverse()) { + pageBuffer[bufferPointer--] = line; + if (bufferPointer < 0) break; + } + layoutPointer--; + } + + return pageBuffer.filter((line) => typeof line === 'string'); +} diff --git a/packages/core/src/lib/pagination/lines.test.mts b/packages/core/src/lib/pagination/lines.test.mts new file mode 100644 index 000000000..11bd240f9 --- /dev/null +++ b/packages/core/src/lib/pagination/lines.test.mts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest'; +import { lines } from './lines.mjs'; + +function renderResult(result) { + return `\n${result.join('\n')}\n`; +} + +describe('lines(...)', () => { + const items = [{ value: 1 }, { value: 2 }, { value: 3 }, { value: 4 }]; + + const renderLines = + (itemHeight: number) => + ({ + item: { value }, + isActive, + index, + }: { + item: { value: number }; + isActive: boolean; + index: number; + }): string => + new Array(itemHeight) + .fill(0) + .map((_, i) => { + if (i === 0) { + const pointer = isActive ? '>' : ' '; + const prefix = itemHeight === 1 ? '' : '┌'; + return `${pointer} ${prefix} value:${value} index:${index + 1}`; + } + + const prefix = i === itemHeight - 1 ? '└' : '├'; + return ` ${prefix} value:${value}`; + }) + .join('\n'); + + describe('given the active item can be rendered completely at given position', () => { + const renderItem = renderLines(3); + + it('should return expected pointer', () => { + const result = lines({ + items, + active: 2, + pageSize: 5, + position: 2, + renderItem, + width: 20, + }); + expect(renderResult(result)).toMatchInlineSnapshot(` + " + ├ value:2 + └ value:2 + > ┌ value:3 index:3 + ├ value:3 + └ value:3 + " + `); + }); + }); + + describe('given the active item can be rendered completely only at earlier position', () => { + const renderItem = renderLines(4); + + it('should return expected pointer', () => { + const result = lines({ + items, + active: 2, + pageSize: 5, + position: 2, + renderItem, + width: 20, + }); + expect(renderResult(result)).toMatchInlineSnapshot(` + " + └ value:2 + > ┌ value:3 index:3 + ├ value:3 + ├ value:3 + └ value:3 + " + `); + }); + }); + + describe('given the active item can be rendered completely only at top', () => { + const renderItem = renderLines(5); + + it('should return expected pointer', () => { + const result = lines({ + items, + active: 2, + pageSize: 5, + position: 2, + renderItem, + width: 20, + }); + expect(renderResult(result)).toMatchInlineSnapshot(` + " + > ┌ value:3 index:3 + ├ value:3 + ├ value:3 + ├ value:3 + └ value:3 + " + `); + }); + }); + + describe('given the active item cannot be rendered completely at any position', () => { + const renderItem = renderLines(6); + + it('should return expected pointer', () => { + const result = lines({ + items, + active: 2, + pageSize: 5, + position: 2, + renderItem, + width: 20, + }); + expect(renderResult(result)).toMatchInlineSnapshot(` + " + > ┌ value:3 index:3 + ├ value:3 + ├ value:3 + ├ value:3 + ├ value:3 + " + `); + }); + }); +}); diff --git a/packages/core/src/lib/pagination/position.mts b/packages/core/src/lib/pagination/position.mts new file mode 100644 index 000000000..ce9a12f19 --- /dev/null +++ b/packages/core/src/lib/pagination/position.mts @@ -0,0 +1,46 @@ +/** + * Creates the next position for the active item considering a finite list of + * items to be rendered on a page. + */ +export function finite({ + active, + pageSize, + total, +}: { + active: number; + pageSize: number; + total: number; +}): number { + const middle = Math.floor(pageSize / 2); + if (total <= pageSize || active < middle) return active; + if (active >= total - middle) return active + pageSize - total; + return middle; +} + +/** + * Creates the next position for the active item considering an infinitely + * looping list of items to be rendered on the page. + */ +export function infinite({ + active, + lastActive, + total, + pageSize, + pointer, +}: { + active: number; + lastActive: number; + total: number; + pageSize: number; + pointer: number; +}): number { + if (total <= pageSize) return active; + + // Move the position only when the user moves down, and when the + // navigation fits within a single page + if (lastActive < active && active - lastActive < pageSize) { + // Limit it to the middle of the list + return Math.min(Math.floor(pageSize / 2), pointer + active - lastActive); + } + return pointer; +} diff --git a/packages/core/src/lib/pagination/use-pagination.mts b/packages/core/src/lib/pagination/use-pagination.mts new file mode 100644 index 000000000..76d133945 --- /dev/null +++ b/packages/core/src/lib/pagination/use-pagination.mts @@ -0,0 +1,58 @@ +import chalk from 'chalk'; +import { type Prettify } from '@inquirer/type'; +import { useRef } from '../use-ref.mjs'; +import { readlineWidth } from '../utils.mjs'; +import { lines, type Layout } from './lines.mjs'; +import { finite, infinite } from './position.mjs'; + +export function usePagination({ + items, + active, + renderItem, + pageSize = 7, + loop = true, +}: { + items: readonly T[]; + /** The index of the active item. */ + active: number; + /** Renders an item as part of a page. */ + renderItem: (layout: Prettify>) => string; + /** The size of the page. `7` if unspecified. */ + pageSize?: number; + /** Allows creating an infinitely looping list. `true` if unspecified. */ + loop?: boolean; +}): string { + const state = useRef({ position: 0, lastActive: 0 }); + + const position = loop + ? infinite({ + active, + lastActive: state.current.lastActive, + total: items.length, + pageSize, + pointer: state.current.position, + }) + : finite({ + active, + total: items.length, + pageSize, + }); + + state.current.position = position; + state.current.lastActive = active; + + const visibleLines = lines({ + items, + width: readlineWidth(), + renderItem, + active, + position, + pageSize, + }).join('\n'); + + if (items.length > pageSize) { + return `${visibleLines}\n${chalk.dim('(Use arrow keys to reveal more choices)')}`; + } + + return visibleLines; +} diff --git a/packages/core/src/lib/use-pagination.mts b/packages/core/src/lib/use-pagination.mts deleted file mode 100644 index d9e3a7bc3..000000000 --- a/packages/core/src/lib/use-pagination.mts +++ /dev/null @@ -1,44 +0,0 @@ -import chalk from 'chalk'; -import { breakLines, readlineWidth } from './utils.mjs'; -import { useRef } from './use-ref.mjs'; - -export function usePagination( - output: string, - { - active, - pageSize = 7, - }: { - active: number; - pageSize?: number; - }, -) { - const state = useRef({ - pointer: 0, - lastIndex: 0, - }); - - const width = readlineWidth(); - const lines = breakLines(output, width).split('\n'); - - // Make sure there's enough lines to paginate - if (lines.length <= pageSize) { - return output; - } - - const middleOfList = Math.floor(pageSize / 2); - - // Move the pointer only when the user go down and limit it to the middle of the list - const { pointer: prevPointer, lastIndex } = state.current; - if (prevPointer < middleOfList && lastIndex < active && active - lastIndex < pageSize) { - state.current.pointer = Math.min(middleOfList, prevPointer + active - lastIndex); - } - - state.current.lastIndex = active; - - // Duplicate the lines so it give an infinite list look - const infinite = [lines, lines, lines].flat(); - const topIndex = Math.max(0, active + lines.length - state.current.pointer); - - const section = infinite.splice(topIndex, pageSize).join('\n'); - return section + '\n' + chalk.dim('(Use arrow keys to reveal more choices)'); -} diff --git a/packages/demo/demos/select.mjs b/packages/demo/demos/select.mjs index 776bb0177..efe4237b8 100644 --- a/packages/demo/demos/select.mjs +++ b/packages/demo/demos/select.mjs @@ -1,6 +1,35 @@ import * as url from 'node:url'; import { select, Separator } from '@inquirer/prompts'; +const alphabet = [ + { value: 'A' }, + { value: 'B' }, + { value: 'C' }, + { value: 'D' }, + { value: 'E' }, + { value: 'F' }, + { value: 'G' }, + { value: 'H' }, + { value: 'I' }, + { value: 'J' }, + { value: 'K' }, + { value: 'L' }, + { value: 'M' }, + { value: 'N' }, + { value: 'O', description: 'Letter O, not number 0' }, + { value: 'P' }, + { value: 'Q' }, + { value: 'R' }, + { value: 'S' }, + { value: 'T' }, + { value: 'U' }, + { value: 'V' }, + { value: 'W' }, + { value: 'X' }, + { value: 'Y' }, + { value: 'Z' }, +]; + const demo = async () => { let answer; @@ -28,33 +57,18 @@ const demo = async () => { message: 'Select your favorite letter', choices: [ new Separator('== Alphabet (choices cycle as you scroll through) =='), - { value: 'A' }, - { value: 'B' }, - { value: 'C' }, - { value: 'D' }, - { value: 'E' }, - { value: 'F' }, - { value: 'G' }, - { value: 'H' }, - { value: 'I' }, - { value: 'J' }, - { value: 'K' }, - { value: 'L' }, - { value: 'M' }, - { value: 'N' }, - { value: 'O', description: 'Letter O, not number 0' }, - { value: 'P' }, - { value: 'Q' }, - { value: 'R' }, - { value: 'S' }, - { value: 'T' }, - { value: 'U' }, - { value: 'V' }, - { value: 'W' }, - { value: 'X' }, - { value: 'Y' }, - { value: 'Z' }, + ...alphabet, + ], + }); + console.log('Answer:', answer); + + answer = await select({ + message: 'Select your favorite letter (example without loop)', + choices: [ + new Separator('== Alphabet (choices cycle as you scroll through) =='), + ...alphabet, ], + loop: false, }); console.log('Answer:', answer); }; diff --git a/packages/select/select.test.mts b/packages/select/select.test.mts index 1d8feb653..5bb0cfd89 100644 --- a/packages/select/select.test.mts +++ b/packages/select/select.test.mts @@ -152,6 +152,120 @@ describe('select prompt', () => { await expect(answer).resolves.toEqual(11); }); + it('does not scroll up beyond first item when not looping', async () => { + const { answer, events, getScreen } = await render(select, { + message: 'Select a number', + choices: numberedChoices, + pageSize: 2, + loop: false, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Use arrow keys) + ❯ 1 + 2 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('up'); + events.keypress('up'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Use arrow keys) + ❯ 1 + 2 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual(1); + }); + + it('does not scroll up beyond first selectable item when not looping', async () => { + const { answer, events, getScreen } = await render(select, { + message: 'Select a number', + choices: [new Separator(), ...numberedChoices], + pageSize: 2, + loop: false, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Use arrow keys) + ────────────── + ❯ 1 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('up'); + events.keypress('up'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Use arrow keys) + ────────────── + ❯ 1 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual(1); + }); + + it('does not scroll down beyond last item when not looping', async () => { + const { answer, events, getScreen } = await render(select, { + message: 'Select a number', + choices: numberedChoices, + pageSize: 2, + loop: false, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Use arrow keys) + ❯ 1 + 2 + (Use arrow keys to reveal more choices)" + `); + + numberedChoices.forEach(() => events.keypress('down')); + events.keypress('down'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + 11 + ❯ 12 + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual(numberedChoices.length); + }); + + it('does not scroll down beyond last selectable item when not looping', async () => { + const { answer, events, getScreen } = await render(select, { + message: 'Select a number', + choices: [...numberedChoices, new Separator()], + pageSize: 3, + loop: false, + }); + + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number (Use arrow keys) + ❯ 1 + 2 + 3 + (Use arrow keys to reveal more choices)" + `); + + numberedChoices.forEach(() => events.keypress('down')); + events.keypress('down'); + expect(getScreen()).toMatchInlineSnapshot(` + "? Select a number + 11 + ❯ 12 + ────────────── + (Use arrow keys to reveal more choices)" + `); + + events.keypress('enter'); + await expect(answer).resolves.toEqual(numberedChoices.length); + }); + it('skip disabled options by arrow keys', async () => { const { answer, events, getScreen } = await render(select, { message: 'Select a topping', diff --git a/packages/select/src/index.mts b/packages/select/src/index.mts index beff39edf..b9d2d1834 100644 --- a/packages/select/src/index.mts +++ b/packages/select/src/index.mts @@ -5,6 +5,7 @@ import { usePrefix, usePagination, useRef, + useMemo, isEnterKey, isUpKey, isDownKey, @@ -28,6 +29,7 @@ type Choice = { type SelectConfig = PromptConfig<{ choices: ReadonlyArray | Separator>; pageSize?: number; + loop?: boolean; }>; type Item = Separator | Choice; @@ -58,18 +60,23 @@ export default createPrompt( config: SelectConfig, done: (value: Value) => void, ): string => { - const { choices: items, pageSize } = config; + const { choices: items, loop = true, pageSize } = config; const firstRender = useRef(true); const prefix = usePrefix(); const [status, setStatus] = useState('pending'); - const [active, setActive] = useState(() => { - const selected = items.findIndex(isSelectable); - if (selected < 0) + + const bounds = useMemo(() => { + const first = items.findIndex(isSelectable); + // TODO: Replace with `findLastIndex` when it's available. + const last = items.length - 1 - [...items].reverse().findIndex(isSelectable); + if (first < 0) throw new Error( '[select prompt] No selectable choices. All choices are disabled.', ); - return selected; - }); + return { first, last }; + }, [items]); + + const [active, setActive] = useState(bounds.first); // Safe to assume the cursor position always point to a Choice. const selectedChoice = items[active] as Choice; @@ -79,6 +86,8 @@ export default createPrompt( setStatus('done'); done(selectedChoice.value); } else if (isUpKey(key) || isDownKey(key)) { + if (!loop && active === bounds.first && isUpKey(key)) return; + if (!loop && active === bounds.last && isDownKey(key)) return; const offset = isUpKey(key) ? -1 : 1; let next = active; do { @@ -99,13 +108,12 @@ export default createPrompt( message += chalk.dim(' (Use arrow keys)'); } - const lines = items - .map((item, index) => renderItem({ item, isActive: index === active })) - .join('\n'); - - const page = usePagination(lines, { + const page = usePagination>({ + items, active, + renderItem, pageSize, + loop, }); if (status === 'done') {