diff --git a/.changeset/shy-ideas-shout.md b/.changeset/shy-ideas-shout.md new file mode 100644 index 00000000..3ebae9b1 --- /dev/null +++ b/.changeset/shy-ideas-shout.md @@ -0,0 +1,5 @@ +--- +"@clack/prompts": minor +--- + +Added new `box` prompt for rendering boxed text, similar a note. diff --git a/packages/prompts/src/box.ts b/packages/prompts/src/box.ts new file mode 100644 index 00000000..4a4894bc --- /dev/null +++ b/packages/prompts/src/box.ts @@ -0,0 +1,128 @@ +import type { Writable } from 'node:stream'; +import { getColumns } from '@clack/core'; +import wrap from 'wrap-ansi'; +import { + type CommonOptions, + S_BAR, + S_BAR_END, + S_BAR_END_RIGHT, + S_BAR_H, + S_BAR_START, + S_BAR_START_RIGHT, + S_CORNER_BOTTOM_LEFT, + S_CORNER_BOTTOM_RIGHT, + S_CORNER_TOP_LEFT, + S_CORNER_TOP_RIGHT, +} from './common.js'; + +export type BoxAlignment = 'left' | 'center' | 'right'; + +type BoxSymbols = [topLeft: string, topRight: string, bottomLeft: string, bottomRight: string]; + +const roundedSymbols: BoxSymbols = [ + S_CORNER_TOP_LEFT, + S_CORNER_TOP_RIGHT, + S_CORNER_BOTTOM_LEFT, + S_CORNER_BOTTOM_RIGHT, +]; +const squareSymbols: BoxSymbols = [S_BAR_START, S_BAR_START_RIGHT, S_BAR_END, S_BAR_END_RIGHT]; + +export interface BoxOptions extends CommonOptions { + contentAlign?: BoxAlignment; + titleAlign?: BoxAlignment; + width?: number | 'auto'; + titlePadding?: number; + contentPadding?: number; + rounded?: boolean; + includePrefix?: boolean; + formatBorder?: (text: string) => string; +} + +function getPaddingForLine( + lineLength: number, + innerWidth: number, + padding: number, + contentAlign: BoxAlignment | undefined +): [number, number] { + let leftPadding = padding; + let rightPadding = padding; + if (contentAlign === 'center') { + leftPadding = Math.floor((innerWidth - lineLength) / 2); + } else if (contentAlign === 'right') { + leftPadding = innerWidth - lineLength - padding; + } + + rightPadding = innerWidth - leftPadding - lineLength; + + return [leftPadding, rightPadding]; +} + +const defaultFormatBorder = (text: string) => text; + +export const box = (message = '', title = '', opts?: BoxOptions) => { + const output: Writable = opts?.output ?? process.stdout; + const columns = getColumns(output); + const borderWidth = 1; + const borderTotalWidth = borderWidth * 2; + const titlePadding = opts?.titlePadding ?? 1; + const contentPadding = opts?.contentPadding ?? 2; + const width = opts?.width === undefined || opts.width === 'auto' ? 1 : Math.min(1, opts.width); + const linePrefix = opts?.includePrefix ? `${S_BAR} ` : ''; + const formatBorder = opts?.formatBorder ?? defaultFormatBorder; + const symbols = (opts?.rounded ? roundedSymbols : squareSymbols).map(formatBorder); + const hSymbol = formatBorder(S_BAR_H); + const vSymbol = formatBorder(S_BAR); + const maxBoxWidth = columns - linePrefix.length; + let boxWidth = Math.floor(columns * width) - linePrefix.length; + if (opts?.width === 'auto') { + const lines = message.split('\n'); + let longestLine = title.length + titlePadding * 2; + for (const line of lines) { + const lineWithPadding = line.length + contentPadding * 2; + if (lineWithPadding > longestLine) { + longestLine = lineWithPadding; + } + } + const longestLineWidth = longestLine + borderTotalWidth; + if (longestLineWidth < boxWidth) { + boxWidth = longestLineWidth; + } + } + if (boxWidth % 2 !== 0) { + if (boxWidth < maxBoxWidth) { + boxWidth++; + } else { + boxWidth--; + } + } + const innerWidth = boxWidth - borderTotalWidth; + const maxTitleLength = innerWidth - titlePadding * 2; + const truncatedTitle = + title.length > maxTitleLength ? `${title.slice(0, maxTitleLength - 3)}...` : title; + const [titlePaddingLeft, titlePaddingRight] = getPaddingForLine( + truncatedTitle.length, + innerWidth, + titlePadding, + opts?.titleAlign + ); + const wrappedMessage = wrap(message, innerWidth - contentPadding * 2, { + hard: true, + trim: false, + }); + output.write( + `${linePrefix}${symbols[0]}${hSymbol.repeat(titlePaddingLeft)}${truncatedTitle}${hSymbol.repeat(titlePaddingRight)}${symbols[1]}\n` + ); + const wrappedLines = wrappedMessage.split('\n'); + for (const line of wrappedLines) { + const [leftLinePadding, rightLinePadding] = getPaddingForLine( + line.length, + innerWidth, + contentPadding, + opts?.contentAlign + ); + output.write( + `${linePrefix}${vSymbol}${' '.repeat(leftLinePadding)}${line}${' '.repeat(rightLinePadding)}${vSymbol}\n` + ); + } + output.write(`${linePrefix}${symbols[2]}${hSymbol.repeat(innerWidth)}${symbols[3]}\n`); +}; diff --git a/packages/prompts/src/common.ts b/packages/prompts/src/common.ts index 3df38cdf..57670ab3 100644 --- a/packages/prompts/src/common.ts +++ b/packages/prompts/src/common.ts @@ -17,6 +17,8 @@ export const S_STEP_SUBMIT = unicodeOr('◇', 'o'); export const S_BAR_START = unicodeOr('┌', 'T'); export const S_BAR = unicodeOr('│', '|'); export const S_BAR_END = unicodeOr('└', '—'); +export const S_BAR_START_RIGHT = unicodeOr('┐', 'T'); +export const S_BAR_END_RIGHT = unicodeOr('┘', '—'); export const S_RADIO_ACTIVE = unicodeOr('●', '>'); export const S_RADIO_INACTIVE = unicodeOr('○', ' '); @@ -29,6 +31,8 @@ export const S_BAR_H = unicodeOr('─', '-'); export const S_CORNER_TOP_RIGHT = unicodeOr('╮', '+'); export const S_CONNECT_LEFT = unicodeOr('├', '+'); export const S_CORNER_BOTTOM_RIGHT = unicodeOr('╯', '+'); +export const S_CORNER_BOTTOM_LEFT = unicodeOr('╰', '+'); +export const S_CORNER_TOP_LEFT = unicodeOr('╭', '+'); export const S_INFO = unicodeOr('●', '•'); export const S_SUCCESS = unicodeOr('◆', '*'); diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index 4bf752b1..dd82aeef 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -1,6 +1,7 @@ export { type ClackSettings, isCancel, settings, updateSettings } from '@clack/core'; export * from './autocomplete.js'; +export * from './box.js'; export * from './common.js'; export * from './confirm.js'; export * from './group.js'; diff --git a/packages/prompts/test/__snapshots__/box.test.ts.snap b/packages/prompts/test/__snapshots__/box.test.ts.snap new file mode 100644 index 00000000..60cc91ec --- /dev/null +++ b/packages/prompts/test/__snapshots__/box.test.ts.snap @@ -0,0 +1,465 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`box (isCI = false) > cannot have width larger than 100% 1`] = ` +[ + "┌─title────────────────────────────────────────────────────────────────────────┐ +", + "│ message │ +", + "└──────────────────────────────────────────────────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders as specified width 1`] = ` +[ + "┌─title────────────────────────────────┐ +", + "│ short │ +", + "│ somewhat questionably long line │ +", + "└──────────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders as wide as longest line with width: auto 1`] = ` +[ + "┌─title──────────────────────────────┐ +", + "│ short │ +", + "│ somewhat questionably long line │ +", + "└────────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders auto width with content longer than title 1`] = ` +[ + "┌─title──────────────────────────┐ +", + "│ messagemessagemessagemessage │ +", + "└────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders auto width with title longer than content 1`] = ` +[ + "┌─titletitletitletitle─┐ +", + "│ message │ +", + "└──────────────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders center aligned content 1`] = ` +[ + "┌─title──────┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders center aligned title 1`] = ` +[ + "┌───title────┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders left aligned content 1`] = ` +[ + "┌─title──────┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders left aligned title 1`] = ` +[ + "┌─title──────┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders message 1`] = ` +[ + "┌──────────────────────────────────────────────────────────────────────────────┐ +", + "│ message │ +", + "└──────────────────────────────────────────────────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders message with title 1`] = ` +[ + "┌─some title───────────────────────────────────────────────────────────────────┐ +", + "│ message │ +", + "└──────────────────────────────────────────────────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders right aligned content 1`] = ` +[ + "┌─title──────┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders right aligned title 1`] = ` +[ + "┌──────title─┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders rounded corners when rounded is true 1`] = ` +[ + "╭─title──────╮ +", + "│ message │ +", + "╰────────────╯ +", +] +`; + +exports[`box (isCI = false) > renders specified contentPadding 1`] = ` +[ + "┌─title──────────────┐ +", + "│ message │ +", + "└────────────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders specified titlePadding 1`] = ` +[ + "┌──────title───────┐ +", + "│ message │ +", + "└──────────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders truncated long titles 1`] = ` +[ + "┌─foofoofoo...─┐ +", + "│ message │ +", + "└──────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders with formatBorder formatting 1`] = ` +[ + "┌─title──────┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = false) > renders with prefix when includePrefix is true 1`] = ` +[ + "│ ┌─title──────┐ +", + "│ │ message │ +", + "│ └────────────┘ +", +] +`; + +exports[`box (isCI = false) > wraps content to fit within specified width 1`] = ` +[ + "┌─title────────────────────────────────┐ +", + "│ foo barfoo barfoo barfoo barfoo │ +", + "│ barfoo barfoo barfoo barfoo barfoo │ +", + "│ barfoo barfoo barfoo barfoo │ +", + "│ barfoo barfoo barfoo barfoo barfoo │ +", + "│ barfoo bar │ +", + "└──────────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = true) > cannot have width larger than 100% 1`] = ` +[ + "┌─title────────────────────────────────────────────────────────────────────────┐ +", + "│ message │ +", + "└──────────────────────────────────────────────────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders as specified width 1`] = ` +[ + "┌─title────────────────────────────────┐ +", + "│ short │ +", + "│ somewhat questionably long line │ +", + "└──────────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders as wide as longest line with width: auto 1`] = ` +[ + "┌─title──────────────────────────────┐ +", + "│ short │ +", + "│ somewhat questionably long line │ +", + "└────────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders auto width with content longer than title 1`] = ` +[ + "┌─title──────────────────────────┐ +", + "│ messagemessagemessagemessage │ +", + "└────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders auto width with title longer than content 1`] = ` +[ + "┌─titletitletitletitle─┐ +", + "│ message │ +", + "└──────────────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders center aligned content 1`] = ` +[ + "┌─title──────┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders center aligned title 1`] = ` +[ + "┌───title────┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders left aligned content 1`] = ` +[ + "┌─title──────┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders left aligned title 1`] = ` +[ + "┌─title──────┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders message 1`] = ` +[ + "┌──────────────────────────────────────────────────────────────────────────────┐ +", + "│ message │ +", + "└──────────────────────────────────────────────────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders message with title 1`] = ` +[ + "┌─some title───────────────────────────────────────────────────────────────────┐ +", + "│ message │ +", + "└──────────────────────────────────────────────────────────────────────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders right aligned content 1`] = ` +[ + "┌─title──────┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders right aligned title 1`] = ` +[ + "┌──────title─┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders rounded corners when rounded is true 1`] = ` +[ + "╭─title──────╮ +", + "│ message │ +", + "╰────────────╯ +", +] +`; + +exports[`box (isCI = true) > renders specified contentPadding 1`] = ` +[ + "┌─title──────────────┐ +", + "│ message │ +", + "└────────────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders specified titlePadding 1`] = ` +[ + "┌──────title───────┐ +", + "│ message │ +", + "└──────────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders truncated long titles 1`] = ` +[ + "┌─foofoofoo...─┐ +", + "│ message │ +", + "└──────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders with formatBorder formatting 1`] = ` +[ + "┌─title──────┐ +", + "│ message │ +", + "└────────────┘ +", +] +`; + +exports[`box (isCI = true) > renders with prefix when includePrefix is true 1`] = ` +[ + "│ ┌─title──────┐ +", + "│ │ message │ +", + "│ └────────────┘ +", +] +`; + +exports[`box (isCI = true) > wraps content to fit within specified width 1`] = ` +[ + "┌─title────────────────────────────────┐ +", + "│ foo barfoo barfoo barfoo barfoo │ +", + "│ barfoo barfoo barfoo barfoo barfoo │ +", + "│ barfoo barfoo barfoo barfoo │ +", + "│ barfoo barfoo barfoo barfoo barfoo │ +", + "│ barfoo bar │ +", + "└──────────────────────────────────────┘ +", +] +`; diff --git a/packages/prompts/test/box.test.ts b/packages/prompts/test/box.test.ts new file mode 100644 index 00000000..69cd205d --- /dev/null +++ b/packages/prompts/test/box.test.ts @@ -0,0 +1,237 @@ +import colors from 'picocolors'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; +import * as prompts from '../src/index.js'; +import { MockReadable, MockWritable } from './test-utils.js'; + +describe.each(['true', 'false'])('box (isCI = %s)', (isCI) => { + let originalCI: string | undefined; + let output: MockWritable; + let input: MockReadable; + + beforeAll(() => { + originalCI = process.env.CI; + process.env.CI = isCI; + }); + + afterAll(() => { + process.env.CI = originalCI; + }); + + beforeEach(() => { + output = new MockWritable(); + input = new MockReadable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('renders message', () => { + prompts.box('message', undefined, { + input, + output, + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders message with title', () => { + prompts.box('message', 'some title', { + input, + output, + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders as wide as longest line with width: auto', () => { + prompts.box('short\nsomewhat questionably long line', 'title', { + input, + output, + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders as specified width', () => { + prompts.box('short\nsomewhat questionably long line', 'title', { + input, + output, + width: 0.5, + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('wraps content to fit within specified width', () => { + prompts.box('foo bar'.repeat(20), 'title', { + input, + output, + width: 0.5, + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders specified titlePadding', () => { + prompts.box('message', 'title', { + input, + output, + titlePadding: 6, + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders specified contentPadding', () => { + prompts.box('message', 'title', { + input, + output, + contentPadding: 6, + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders with prefix when includePrefix is true', () => { + prompts.box('message', 'title', { + input, + output, + includePrefix: true, + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders truncated long titles', () => { + prompts.box('message', 'foo'.repeat(20), { + input, + output, + width: 0.2, + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('cannot have width larger than 100%', () => { + prompts.box('message', 'title', { + input, + output, + width: 1.1, + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders rounded corners when rounded is true', () => { + prompts.box('message', 'title', { + input, + output, + rounded: true, + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders auto width with content longer than title', () => { + prompts.box('message'.repeat(4), 'title', { + input, + output, + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders auto width with title longer than content', () => { + prompts.box('message', 'title'.repeat(4), { + input, + output, + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders left aligned title', () => { + prompts.box('message', 'title', { + input, + output, + titleAlign: 'left', + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders right aligned title', () => { + prompts.box('message', 'title', { + input, + output, + titleAlign: 'right', + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders center aligned title', () => { + prompts.box('message', 'title', { + input, + output, + titleAlign: 'center', + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders left aligned content', () => { + prompts.box('message', 'title', { + input, + output, + contentAlign: 'left', + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders right aligned content', () => { + prompts.box('message', 'title', { + input, + output, + contentAlign: 'right', + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders center aligned content', () => { + prompts.box('message', 'title', { + input, + output, + contentAlign: 'center', + width: 'auto', + }); + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders with formatBorder formatting', () => { + prompts.box('message', 'title', { + input, + output, + width: 'auto', + formatBorder: colors.red, + }); + + expect(output.buffer).toMatchSnapshot(); + }); +});