diff --git a/.changeset/tangy-mirrors-hug.md b/.changeset/tangy-mirrors-hug.md new file mode 100644 index 00000000..61e67153 --- /dev/null +++ b/.changeset/tangy-mirrors-hug.md @@ -0,0 +1,6 @@ +--- +"@clack/prompts": minor +"@clack/core": minor +--- + +Adds `date` prompt with format support (YYYY/MM/DD, MM/DD/YYYY, DD/MM/YYYY). diff --git a/examples/basic/date.ts b/examples/basic/date.ts new file mode 100644 index 00000000..69620b58 --- /dev/null +++ b/examples/basic/date.ts @@ -0,0 +1,24 @@ +import * as p from '@clack/prompts'; +import color from 'picocolors'; + +async function main() { + const result = await p.date({ + message: color.magenta('Pick a date'), + format: 'YYYY/MM/DD', + }); + + const secondResult = await p.date({ + message: color.magenta('Modify this date:'), + format: 'YYYY/MM/DD', + defaultValue: '2025-01-01', + }); + + if (p.isCancel(result) || p.isCancel(secondResult)) { + p.cancel('Operation cancelled.'); + process.exit(0); + } + + p.outro(`Selected dates: ${color.cyan(result)} and ${color.cyan(secondResult)}`); +} + +main().catch(console.error); diff --git a/examples/basic/package.json b/examples/basic/package.json index f8e617e7..14e8b1fa 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -14,7 +14,8 @@ "progress": "jiti ./progress.ts", "spinner": "jiti ./spinner.ts", "path": "jiti ./path.ts", - "spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts", + "date": "jiti ./date.ts", + "spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts", "spinner-timer": "jiti ./spinner-timer.ts", "task-log": "jiti ./task-log.ts" }, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7299d075..87de11a8 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,6 +2,8 @@ export type { AutocompleteOptions } from './prompts/autocomplete.js'; export { default as AutocompletePrompt } from './prompts/autocomplete.js'; export type { ConfirmOptions } from './prompts/confirm.js'; export { default as ConfirmPrompt } from './prompts/confirm.js'; +export type { DateFormat, DateOptions } from './prompts/date.js'; +export { default as DatePrompt } from './prompts/date.js'; export type { GroupMultiSelectOptions } from './prompts/group-multiselect.js'; export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect.js'; export type { MultiSelectOptions } from './prompts/multi-select.js'; diff --git a/packages/core/src/prompts/date.ts b/packages/core/src/prompts/date.ts new file mode 100644 index 00000000..8171e0b5 --- /dev/null +++ b/packages/core/src/prompts/date.ts @@ -0,0 +1,404 @@ +import type { Key } from 'node:readline'; +import color from 'picocolors'; +import Prompt, { type PromptOptions } from './prompt.js'; + +export type DateFormat = 'YYYY/MM/DD' | 'MM/DD/YYYY' | 'DD/MM/YYYY'; + +interface SegmentConfig { + type: 'year' | 'month' | 'day'; + start: number; + len: number; +} + +interface FormatConfig { + segments: SegmentConfig[]; + displayTemplate: string; + format: (year: string, month: string, day: string) => string; +} + +const SEGMENT_TYPES: Record = { + 'YYYY/MM/DD': ['year', 'month', 'day'], + 'MM/DD/YYYY': ['month', 'day', 'year'], + 'DD/MM/YYYY': ['day', 'month', 'year'], +}; + +// All formatters take (year, month, day) and return display string in format order +const formatters: Record string> = { + 'YYYY/MM/DD': (y, m, d) => `${y}/${m}/${d}`, + 'MM/DD/YYYY': (y, m, d) => `${m}/${d}/${y}`, + 'DD/MM/YYYY': (y, m, d) => `${d}/${m}/${y}`, +}; + +function buildFormatConfig(format: DateFormat): FormatConfig { + const formatFn = formatters[format]; + const displayTemplate = formatFn('____', '__', '__'); + const types = SEGMENT_TYPES[format]; + const parts = displayTemplate.split('/'); + let start = 0; + const segments: SegmentConfig[] = parts.map((part, i) => { + const seg: SegmentConfig = { type: types[i], start, len: part.length }; + start += part.length + 1; + return seg; + }); + return { segments, displayTemplate, format: formatFn }; +} + +const FORMAT_CONFIGS: Record = { + 'YYYY/MM/DD': buildFormatConfig('YYYY/MM/DD'), + 'MM/DD/YYYY': buildFormatConfig('MM/DD/YYYY'), + 'DD/MM/YYYY': buildFormatConfig('DD/MM/YYYY'), +}; + +function parseDisplayString( + display: string, + config: FormatConfig +): { year: number; month: number; day: number } { + const result = { year: 0, month: 0, day: 0 }; + for (const seg of config.segments) { + const val = display.slice(seg.start, seg.start + seg.len).replace(/_/g, '0') || '0'; + const num = Number.parseInt(val, 10); + if (seg.type === 'year') result.year = num; + else if (seg.type === 'month') result.month = num; + else result.day = num; + } + return result; +} + +function formatDisplayString( + { year, month, day }: { year: number; month: number; day: number }, + config: FormatConfig +): string { + const y = String(year).padStart(4, '0'); + const m = String(month).padStart(2, '0'); + const d = String(day).padStart(2, '0'); + return config.format(y, m, d); +} + +function clampSegment( + value: number, + type: 'year' | 'month' | 'day', + context: { year: number; month: number } +): number { + if (type === 'year') { + return Math.max(1000, Math.min(9999, value || 1000)); + } + if (type === 'month') { + return Math.max(1, Math.min(12, value || 1)); + } + // day - month-aware + const { year, month } = context; + const daysInMonth = new Date(year || 2000, month || 1, 0).getDate(); + return Math.max(1, Math.min(daysInMonth, value || 1)); +} + +function toISOString(display: string, config: FormatConfig): string | undefined { + const { year, month, day } = parseDisplayString(display, config); + if (!year || year < 1000 || year > 9999) return undefined; + if (!month || month < 1 || month > 12) return undefined; + if (!day || day < 1) return undefined; + const date = new Date(year, month - 1, day); + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { + return undefined; + } + return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; +} + +const MONTH_NAMES = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', +]; + +function getSegmentValidationMessage( + newDisplay: string, + seg: SegmentConfig, + config: FormatConfig +): string | undefined { + const { year, month, day } = parseDisplayString(newDisplay, config); + if (seg.type === 'month' && (month < 1 || month > 12)) { + return 'There are only 12 months in a year'; + } + if (seg.type === 'day') { + if (day < 1) return undefined; // incomplete + const daysInMonth = new Date(year || 2000, month || 1, 0).getDate(); + if (day > daysInMonth) { + const monthName = month >= 1 && month <= 12 ? MONTH_NAMES[month - 1] : 'this month'; + return `There are only ${daysInMonth} days in ${monthName}`; + } + } + return undefined; +} + +export interface DateOptions extends PromptOptions { + format?: DateFormat; + defaultValue?: string | Date; +} + +export default class DatePrompt extends Prompt { + #format: DateFormat; + #config: FormatConfig; + + /** Inline validation message shown beneath input during editing (e.g. "There are only 12 months") */ + inlineError = ''; + + get cursor() { + return this._cursor; + } + + get userInputWithCursor() { + const userInput = this.userInput || this.#config.displayTemplate; + const sep = (ch: string) => (ch === '/' ? color.gray(ch) : ch); + if (this.state === 'submit') { + return userInput; // plain string for programmatic use (no ANSI) + } + let result = ''; + for (let i = 0; i < userInput.length; i++) { + const ch = userInput[i]; + if (i === this._cursor) { + result += ch === '_' ? color.inverse(' ') : color.inverse(ch); + } else { + result += sep(ch); // keep '_' as-is for placeholders, grey '/' + } + } + if (this._cursor >= userInput.length) { + result += '█'; + } + return result; + } + + constructor(opts: DateOptions) { + const initialDisplay = DatePrompt.#toDisplayString( + opts.initialValue ?? opts.defaultValue ?? opts.initialUserInput, + opts.format ?? 'YYYY/MM/DD' + ); + super( + { + ...opts, + initialUserInput: initialDisplay, + }, + false + ); + this.#format = opts.format ?? 'YYYY/MM/DD'; + this.#config = FORMAT_CONFIGS[this.#format]; + + this._setUserInput(initialDisplay); + const firstSeg = this.#config.segments[0]; + // Start at beginning for left-to-right digit-by-digit editing + this._cursor = firstSeg.start; + this._setValue(toISOString(initialDisplay, this.#config) ?? undefined); + + this.on('cursor', (key) => this.#onCursor(key)); + this.on('key', (char, key) => this.#onKey(char, key)); + this.on('finalize', () => this.#onFinalize(opts)); + } + + static #toDisplayString(value: string | Date | undefined, format: DateFormat): string { + const config = FORMAT_CONFIGS[format]; + if (!value) return config.displayTemplate; + if (value instanceof Date) { + return formatDisplayString( + { + year: value.getFullYear(), + month: value.getMonth() + 1, + day: value.getDate(), + }, + config + ); + } + const match = value.match(/^(\d{4})-(\d{2})-(\d{2})/); + if (match) { + return formatDisplayString( + { + year: Number.parseInt(match[1], 10), + month: Number.parseInt(match[2], 10), + day: Number.parseInt(match[3], 10), + }, + config + ); + } + return config.displayTemplate; + } + + #getSegmentAtCursor(): { segment: SegmentConfig; index: number } | undefined { + const cursor = this._cursor; + for (let i = 0; i < this.#config.segments.length; i++) { + const seg = this.#config.segments[i]; + if (cursor >= seg.start && cursor < seg.start + seg.len) { + return { segment: seg, index: i }; + } + } + // Cursor might be on separator - find nearest segment + for (let i = 0; i < this.#config.segments.length; i++) { + const seg = this.#config.segments[i]; + if (cursor < seg.start) { + return { segment: this.#config.segments[Math.max(0, i - 1)], index: Math.max(0, i - 1) }; + } + } + return { + segment: this.#config.segments[this.#config.segments.length - 1], + index: this.#config.segments.length - 1, + }; + } + + #onCursor(key?: string) { + if (!key) return; + const ctx = this.#getSegmentAtCursor(); + if (!ctx) return; + + if (key === 'left' || key === 'right') { + this.inlineError = ''; + const seg = ctx.segment; + const posInSeg = this._cursor - seg.start; + const delta = key === 'left' ? -1 : 1; + // Move within segment first, then between segments + const newPosInSeg = posInSeg + delta; + if (newPosInSeg >= 0 && newPosInSeg < seg.len) { + this._cursor = seg.start + newPosInSeg; + return; + } + const newIndex = Math.max(0, Math.min(this.#config.segments.length - 1, ctx.index + delta)); + const newSeg = this.#config.segments[newIndex]; + // Always start at beginning of segment for left-to-right digit-by-digit editing + this._cursor = newSeg.start; + return; + } + + if (key === 'up' || key === 'down') { + const { year, month, day } = parseDisplayString( + this.userInput || this.#config.displayTemplate, + this.#config + ); + const seg = ctx.segment; + const delta = key === 'up' ? 1 : -1; + + let newYear = year; + let newMonth = month; + let newDay = day; + + if (seg.type === 'year') { + newYear = clampSegment(year + delta, 'year', { year, month }); + } else if (seg.type === 'month') { + newMonth = clampSegment(month + delta, 'month', { year, month }); + } else { + newDay = clampSegment(day + delta, 'day', { year, month }); + } + + const display = formatDisplayString( + { year: newYear, month: newMonth, day: newDay }, + this.#config + ); + this._setUserInput(display); + this._setValue(toISOString(display, this.#config) ?? undefined); + } + } + + #onKey(char: string | undefined, key: Key) { + // Backspace at position 0 may send char instead of key.name on some terminals + const isBackspace = + key?.name === 'backspace' || + key?.sequence === '\x7f' || // DEL (common on Mac/Linux) + key?.sequence === '\b' || // BS + char === '\x7f' || // char when key.name missing (e.g. at line start) + char === '\b'; + if (isBackspace) { + this.inlineError = ''; + const ctx = this.#getSegmentAtCursor(); + if (!ctx) return; + const seg = ctx.segment; + const display = this.userInput || this.#config.displayTemplate; + const segmentVal = display.slice(seg.start, seg.start + seg.len); + if (!segmentVal.replace(/_/g, '')) return; // Already blank + + // Clear entire segment on backspace at any position + const newVal = '_'.repeat(seg.len); + const newDisplay = display.slice(0, seg.start) + newVal + display.slice(seg.start + seg.len); + this._setUserInput(newDisplay); + this._cursor = seg.start; // Cursor to start of cleared segment + this._setValue(toISOString(newDisplay, this.#config) ?? undefined); + return; + } + + if (char && /^[0-9]$/.test(char)) { + const ctx = this.#getSegmentAtCursor(); + if (!ctx) return; + const seg = ctx.segment; + const display = this.userInput || this.#config.displayTemplate; + const segmentDisplay = display.slice(seg.start, seg.start + seg.len); + + // Inject at leftmost blank when filling, or at cursor when editing filled segment + // Guarantees left-to-right: "____" → "2___" → "20__" → "202_" → "2025" + const firstBlank = segmentDisplay.indexOf('_'); + const pos = firstBlank >= 0 ? firstBlank : Math.min(this._cursor - seg.start, seg.len - 1); + if (pos < 0 || pos >= seg.len) return; + + const newSegmentVal = segmentDisplay.slice(0, pos) + char + segmentDisplay.slice(pos + 1); + const newDisplay = + display.slice(0, seg.start) + newSegmentVal + display.slice(seg.start + seg.len); + + // Validate month (1-12) and day (1 to daysInMonth) when segment is full + if (!newSegmentVal.includes('_')) { + const validationMsg = getSegmentValidationMessage(newDisplay, seg, this.#config); + if (validationMsg) { + this.inlineError = validationMsg; + return; + } + } + this.inlineError = ''; + + const iso = toISOString(newDisplay, this.#config); + + if (iso) { + const { year, month, day } = parseDisplayString(newDisplay, this.#config); + const clamped = formatDisplayString( + { + year: clampSegment(year, 'year', { year, month }), + month: clampSegment(month, 'month', { year, month }), + day: clampSegment(day, 'day', { year, month }), + }, + this.#config + ); + this._setUserInput(clamped); + this._setValue(iso); + } else { + this._setUserInput(newDisplay); + this._setValue(undefined); + } + + // Cursor: next blank when filling; when segment just became full (was filling), jump to next + const nextBlank = newSegmentVal.indexOf('_'); + const wasFilling = firstBlank >= 0; // had blanks before this keystroke + if (nextBlank >= 0) { + this._cursor = seg.start + nextBlank; + } else if (wasFilling && ctx.index < this.#config.segments.length - 1) { + // Just completed segment by filling - jump to next + this._cursor = this.#config.segments[ctx.index + 1].start; + } else { + // Editing full segment - advance within or stay at end + this._cursor = seg.start + Math.min(pos + 1, seg.len - 1); + } + } + } + + #onFinalize(opts: DateOptions) { + const display = this.userInput || this.#config.displayTemplate; + const iso = toISOString(display, this.#config); + if (iso) { + this.value = iso; + } else { + this.value = opts.defaultValue + ? typeof opts.defaultValue === 'string' + ? opts.defaultValue + : opts.defaultValue.toISOString().slice(0, 10) + : undefined; + } + } +} diff --git a/packages/core/test/prompts/date.test.ts b/packages/core/test/prompts/date.test.ts new file mode 100644 index 00000000..72d1a225 --- /dev/null +++ b/packages/core/test/prompts/date.test.ts @@ -0,0 +1,268 @@ +import color from 'picocolors'; +import { cursor } from 'sisteransi'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { default as DatePrompt } from '../../src/prompts/date.js'; +import { isCancel } from '../../src/utils/index.js'; +import { MockReadable } from '../mock-readable.js'; +import { MockWritable } from '../mock-writable.js'; + +describe('DatePrompt', () => { + let input: MockReadable; + let output: MockWritable; + + beforeEach(() => { + input = new MockReadable(); + output = new MockWritable(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('renders render() result', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + }); + instance.prompt(); + expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); + }); + + test('initial value displays correctly', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + initialValue: '2025-01-15', + }); + instance.prompt(); + expect(instance.userInput).to.equal('2025/01/15'); + expect(instance.value).to.equal('2025-01-15'); + }); + + test('left/right navigates between segments', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + initialValue: '2025-01-15', + }); + instance.prompt(); + expect(instance.cursor).to.equal(0); // year start + // Move within year (0->1->2->3), then right from end goes to month + for (let i = 0; i < 4; i++) { + input.emit('keypress', undefined, { name: 'right' }); + } + expect(instance.cursor).to.equal(5); // month start + for (let i = 0; i < 2; i++) { + input.emit('keypress', undefined, { name: 'right' }); + } + expect(instance.cursor).to.equal(8); // day start + input.emit('keypress', undefined, { name: 'left' }); + expect(instance.cursor).to.equal(5); // month start + }); + + test('up/down increments and decrements segment', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + initialValue: '2025-01-15', + }); + instance.prompt(); + for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month + input.emit('keypress', undefined, { name: 'up' }); + expect(instance.userInput).to.equal('2025/02/15'); + input.emit('keypress', undefined, { name: 'down' }); + expect(instance.userInput).to.equal('2025/01/15'); + }); + + test('digit-by-digit editing from left to right', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + initialValue: '2025-01-15', + }); + instance.prompt(); + expect(instance.cursor).to.equal(0); // year start + // Type 2,0,2,3 to change 2025 -> 2023 (edit digit by digit) + input.emit('keypress', '2', { name: undefined, sequence: '2' }); + input.emit('keypress', '0', { name: undefined, sequence: '0' }); + input.emit('keypress', '2', { name: undefined, sequence: '2' }); + input.emit('keypress', '3', { name: undefined, sequence: '3' }); + expect(instance.userInput).to.equal('2023/01/15'); + expect(instance.cursor).to.equal(3); // end of year segment + }); + + test('backspace clears entire segment at any cursor position', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + initialValue: '2025-12-21', + }); + instance.prompt(); + expect(instance.userInput).to.equal('2025/12/21'); + expect(instance.cursor).to.equal(0); // year start + // Backspace at first position clears whole year segment + input.emit('keypress', undefined, { name: 'backspace', sequence: '\x7f' }); + expect(instance.userInput).to.equal('____/12/21'); + expect(instance.cursor).to.equal(0); + }); + + test('backspace clears segment when cursor at first char (2___)', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + }); + instance.prompt(); + // Type "2" to get "2___" + input.emit('keypress', '2', { name: undefined, sequence: '2' }); + expect(instance.userInput).to.equal('2___/__/__'); + expect(instance.cursor).to.equal(1); // after "2" + // Move to first char (position 0) + input.emit('keypress', undefined, { name: 'left' }); + expect(instance.cursor).to.equal(0); + // Backspace should clear whole segment - also test char-based detection + input.emit('keypress', '\x7f', { name: undefined, sequence: '\x7f' }); + expect(instance.userInput).to.equal('____/__/__'); + expect(instance.cursor).to.equal(0); + }); + + test('digit input updates segment and jumps to next when complete', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + }); + instance.prompt(); + // Type year 2025 - left-to-right, jumps to month when year complete + for (const c of '2025') { + input.emit('keypress', c, { name: undefined, sequence: c }); + } + expect(instance.userInput).to.equal('2025/__/__'); + expect(instance.cursor).to.equal(5); // jumped to month segment start + }); + + test('submit returns ISO string for valid date', async () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + initialValue: '2025-01-31', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', undefined, { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('2025-01-31'); + }); + + test('can cancel', async () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + initialValue: '2025-01-15', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', 'escape', { name: 'escape' }); + const result = await resultPromise; + expect(isCancel(result)).toBe(true); + }); + + test('defaultValue used when invalid date submitted', async () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + defaultValue: '2025-06-15', + }); + const resultPromise = instance.prompt(); + input.emit('keypress', undefined, { name: 'return' }); + const result = await resultPromise; + expect(result).to.equal('2025-06-15'); + }); + + test('supports MM/DD/YYYY format', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + format: 'MM/DD/YYYY', + initialValue: '2025-01-15', + }); + instance.prompt(); + expect(instance.userInput).to.equal('01/15/2025'); + }); + + test('rejects invalid month and shows inline error', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + initialValue: '2025-01-15', // month is 01 + }); + instance.prompt(); + for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month (cursor at start) + input.emit('keypress', '3', { name: undefined, sequence: '3' }); // 0→3 gives 31, invalid + expect(instance.userInput).to.equal('2025/01/15'); // stayed - 31 rejected + expect(instance.inlineError).to.equal('There are only 12 months in a year'); + }); + + test('rejects invalid day and shows inline error', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + initialValue: '2025-01-15', // January has 31 days + }); + instance.prompt(); + for (let i = 0; i < 6; i++) input.emit('keypress', undefined, { name: 'right' }); // move to day (cursor at start) + input.emit('keypress', '4', { name: undefined, sequence: '4' }); // 1→4 gives 45, invalid for Jan + expect(instance.userInput).to.equal('2025/01/15'); // stayed - 45 rejected + expect(instance.inlineError).to.contain('31 days'); + expect(instance.inlineError).to.contain('January'); + }); + + test('supports DD/MM/YYYY format', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + format: 'DD/MM/YYYY', + initialValue: '2025-01-15', + }); + instance.prompt(); + expect(instance.userInput).to.equal('15/01/2025'); + }); + + describe('userInputWithCursor', () => { + test('highlights character at cursor', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + initialValue: '2025-01-15', + }); + instance.prompt(); + for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month + const display = instance.userInputWithCursor; + expect(display).to.contain(color.inverse('0')); // first digit of "01" + }); + + test('returns value on submit', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + initialValue: '2025-01-15', + }); + instance.prompt(); + input.emit('keypress', undefined, { name: 'return' }); + expect(instance.userInputWithCursor).to.equal('2025/01/15'); + }); + }); +}); diff --git a/packages/prompts/src/date.ts b/packages/prompts/src/date.ts new file mode 100644 index 00000000..3d017418 --- /dev/null +++ b/packages/prompts/src/date.ts @@ -0,0 +1,76 @@ +import { DatePrompt, settings } from '@clack/core'; +import type { DateFormat } from '@clack/core'; +import color from 'picocolors'; +import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; + +export interface DateOptions extends CommonOptions { + message: string; + format?: DateFormat; + defaultValue?: string | Date; + initialValue?: string | Date; + validate?: (value: string | undefined) => string | Error | undefined; +} + +export const date = (opts: DateOptions) => { + const validate = opts.validate; + return new DatePrompt({ + format: opts.format ?? 'YYYY/MM/DD', + defaultValue: opts.defaultValue, + initialValue: opts.initialValue, + validate(value) { + if (value === undefined || value === '') { + if (opts.defaultValue !== undefined) { + return undefined; + } + if (validate) { + return validate(value); + } + return 'Please enter a valid date'; + } + if (validate) { + return validate(value); + } + return undefined; + }, + signal: opts.signal, + input: opts.input, + output: opts.output, + render() { + const hasGuide = (opts?.withGuide ?? settings.withGuide) !== false; + const titlePrefix = `${hasGuide ? `${color.gray(S_BAR)}\n` : ''}${symbol(this.state)} `; + const title = `${titlePrefix}${opts.message}\n`; + const userInput = this.userInputWithCursor; + const value = this.value ?? ''; + + switch (this.state) { + case 'error': { + const errorText = this.error ? ` ${color.yellow(this.error)}` : ''; + const errorPrefix = hasGuide ? `${color.yellow(S_BAR)} ` : ''; + const errorPrefixEnd = hasGuide ? color.yellow(S_BAR_END) : ''; + return `${title.trim()}\n${errorPrefix}${userInput}\n${errorPrefixEnd}${errorText}\n`; + } + case 'submit': { + const valueText = value ? ` ${color.dim(value)}` : ''; + const submitPrefix = hasGuide ? color.gray(S_BAR) : ''; + return `${title}${submitPrefix}${valueText}`; + } + case 'cancel': { + const valueText = value ? ` ${color.strikethrough(color.dim(value))}` : ''; + const cancelPrefix = hasGuide ? color.gray(S_BAR) : ''; + return `${title}${cancelPrefix}${valueText}${value.trim() ? `\n${cancelPrefix}` : ''}`; + } + default: { + const defaultPrefix = hasGuide ? `${color.cyan(S_BAR)} ` : ''; + const defaultPrefixEnd = hasGuide ? color.cyan(S_BAR_END) : ''; + // Inline validation: extra bar (│) below date, bar end (└) only at the end + const inlineErrorBar = hasGuide ? `${color.cyan(S_BAR)} ` : ''; + const inlineError = + (this as { inlineError?: string }).inlineError + ? `\n${inlineErrorBar}${color.yellow((this as { inlineError: string }).inlineError)}` + : ''; + return `${title}${defaultPrefix}${userInput}${inlineError}\n${defaultPrefixEnd}\n`; + } + } + }, + }).prompt() as Promise; +}; diff --git a/packages/prompts/src/index.ts b/packages/prompts/src/index.ts index dd82aeef..7dfef5b9 100644 --- a/packages/prompts/src/index.ts +++ b/packages/prompts/src/index.ts @@ -4,6 +4,7 @@ export * from './autocomplete.js'; export * from './box.js'; export * from './common.js'; export * from './confirm.js'; +export * from './date.js'; export * from './group.js'; export * from './group-multi-select.js'; export * from './limit-options.js'; diff --git a/packages/prompts/test/__snapshots__/date.test.ts.snap b/packages/prompts/test/__snapshots__/date.test.ts.snap new file mode 100644 index 00000000..44f06716 --- /dev/null +++ b/packages/prompts/test/__snapshots__/date.test.ts.snap @@ -0,0 +1,263 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`date (isCI = false) > can cancel 1`] = ` +[ + "", + "│ +◆ Pick a date +│  ___/__/__ +└ +", + "", + "", + "", + "■ Pick a date +│", + " +", + "", +] +`; + +exports[`date (isCI = false) > defaultValue used when empty submit 1`] = ` +[ + "", + "│ +◆ Pick a date +│ 2025/12/25 +└ +", + "", + "", + "", + "◇ Pick a date +│ 2025-12-25", + " +", + "", +] +`; + +exports[`date (isCI = false) > renders initial value 1`] = ` +[ + "", + "│ +◆ Pick a date +│ 2025/01/15 +└ +", + "", + "", + "", + "◇ Pick a date +│ 2025-01-15", + " +", + "", +] +`; + +exports[`date (isCI = false) > renders message 1`] = ` +[ + "", + "│ +◆ Pick a date +│ 2025/01/15 +└ +", + "", + "", + "", + "◇ Pick a date +│ 2025-01-15", + " +", + "", +] +`; + +exports[`date (isCI = false) > renders submitted value 1`] = ` +[ + "", + "│ +◆ Pick a date +│ 2025/06/15 +└ +", + "", + "", + "", + "◇ Pick a date +│ 2025-06-15", + " +", + "", +] +`; + +exports[`date (isCI = false) > supports MM/DD/YYYY format 1`] = ` +[ + "", + "│ +◆ Pick a date +│ 01/15/2025 +└ +", + "", + "", + "", + "◇ Pick a date +│ 2025-01-15", + " +", + "", +] +`; + +exports[`date (isCI = false) > withGuide: false removes guide 1`] = ` +[ + "", + "◆ Pick a date +2025/01/15 + +", + "", + "", + "◇ Pick a date + 2025-01-15", + " +", + "", +] +`; + +exports[`date (isCI = true) > can cancel 1`] = ` +[ + "", + "│ +◆ Pick a date +│  ___/__/__ +└ +", + "", + "", + "", + "■ Pick a date +│", + " +", + "", +] +`; + +exports[`date (isCI = true) > defaultValue used when empty submit 1`] = ` +[ + "", + "│ +◆ Pick a date +│ 2025/12/25 +└ +", + "", + "", + "", + "◇ Pick a date +│ 2025-12-25", + " +", + "", +] +`; + +exports[`date (isCI = true) > renders initial value 1`] = ` +[ + "", + "│ +◆ Pick a date +│ 2025/01/15 +└ +", + "", + "", + "", + "◇ Pick a date +│ 2025-01-15", + " +", + "", +] +`; + +exports[`date (isCI = true) > renders message 1`] = ` +[ + "", + "│ +◆ Pick a date +│ 2025/01/15 +└ +", + "", + "", + "", + "◇ Pick a date +│ 2025-01-15", + " +", + "", +] +`; + +exports[`date (isCI = true) > renders submitted value 1`] = ` +[ + "", + "│ +◆ Pick a date +│ 2025/06/15 +└ +", + "", + "", + "", + "◇ Pick a date +│ 2025-06-15", + " +", + "", +] +`; + +exports[`date (isCI = true) > supports MM/DD/YYYY format 1`] = ` +[ + "", + "│ +◆ Pick a date +│ 01/15/2025 +└ +", + "", + "", + "", + "◇ Pick a date +│ 2025-01-15", + " +", + "", +] +`; + +exports[`date (isCI = true) > withGuide: false removes guide 1`] = ` +[ + "", + "◆ Pick a date +2025/01/15 + +", + "", + "", + "◇ Pick a date + 2025-01-15", + " +", + "", +] +`; diff --git a/packages/prompts/test/date.test.ts b/packages/prompts/test/date.test.ts new file mode 100644 index 00000000..d8ded27a --- /dev/null +++ b/packages/prompts/test/date.test.ts @@ -0,0 +1,140 @@ +import { updateSettings } from '@clack/core'; +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'])('date (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(); + updateSettings({ withGuide: true }); + }); + + test('renders message', async () => { + const result = prompts.date({ + message: 'Pick a date', + initialValue: '2025-01-15', + input, + output, + }); + + input.emit('keypress', undefined, { name: 'return' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders initial value', async () => { + const result = prompts.date({ + message: 'Pick a date', + initialValue: '2025-01-15', + input, + output, + }); + + input.emit('keypress', undefined, { name: 'return' }); + + const value = await result; + + expect(value).toBe('2025-01-15'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('can cancel', async () => { + const result = prompts.date({ + message: 'Pick a date', + input, + output, + }); + + input.emit('keypress', 'escape', { name: 'escape' }); + + const value = await result; + + expect(prompts.isCancel(value)).toBe(true); + expect(output.buffer).toMatchSnapshot(); + }); + + test('renders submitted value', async () => { + const result = prompts.date({ + message: 'Pick a date', + initialValue: '2025-06-15', + input, + output, + }); + + input.emit('keypress', undefined, { name: 'return' }); + + const value = await result; + + expect(value).toBe('2025-06-15'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('defaultValue used when empty submit', async () => { + const result = prompts.date({ + message: 'Pick a date', + defaultValue: '2025-12-25', + input, + output, + }); + + input.emit('keypress', undefined, { name: 'return' }); + + const value = await result; + + expect(value).toBe('2025-12-25'); + expect(output.buffer).toMatchSnapshot(); + }); + + test('withGuide: false removes guide', async () => { + const result = prompts.date({ + message: 'Pick a date', + withGuide: false, + initialValue: '2025-01-15', + input, + output, + }); + + input.emit('keypress', undefined, { name: 'return' }); + + await result; + + expect(output.buffer).toMatchSnapshot(); + }); + + test('supports MM/DD/YYYY format', async () => { + const result = prompts.date({ + message: 'Pick a date', + format: 'MM/DD/YYYY', + initialValue: '2025-01-15', + input, + output, + }); + + input.emit('keypress', undefined, { name: 'return' }); + + const value = await result; + + expect(value).toBe('2025-01-15'); + expect(output.buffer).toMatchSnapshot(); + }); +});