From be9d7fc949512e74218a5b66e2dd4538be915211 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:39:25 -0600 Subject: [PATCH 1/8] feat(core, prompts): add DatePrompt for date input with customizable formats --- examples/basic/date.ts | 24 ++ examples/basic/package.json | 3 +- packages/core/src/index.ts | 2 + packages/core/src/prompts/date.ts | 404 ++++++++++++++++++ packages/core/test/prompts/date.test.ts | 268 ++++++++++++ packages/prompts/src/date.ts | 76 ++++ packages/prompts/src/index.ts | 1 + .../test/__snapshots__/date.test.ts.snap | 263 ++++++++++++ packages/prompts/test/date.test.ts | 140 ++++++ 9 files changed, 1180 insertions(+), 1 deletion(-) create mode 100644 examples/basic/date.ts create mode 100644 packages/core/src/prompts/date.ts create mode 100644 packages/core/test/prompts/date.test.ts create mode 100644 packages/prompts/src/date.ts create mode 100644 packages/prompts/test/__snapshots__/date.test.ts.snap create mode 100644 packages/prompts/test/date.test.ts 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(); + }); +}); From 24c385aee2b3198caf9150f942313007bad2ed46 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Sat, 31 Jan 2026 14:53:41 -0600 Subject: [PATCH 2/8] chore: add changeset --- .changeset/tangy-mirrors-hug.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/tangy-mirrors-hug.md 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). From eb1c844b4db2a9eeab293d740e88bbe9988ebc40 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Tue, 3 Feb 2026 19:42:10 -0600 Subject: [PATCH 3/8] refactor: DatePrompt based on @natemoo-re's suggestions --- examples/basic/date.ts | 20 +- packages/core/src/index.ts | 2 +- packages/core/src/prompts/date.ts | 252 ++++++++---------- packages/core/src/utils/settings.ts | 48 ++++ packages/core/test/prompts/date.test.ts | 90 +++++-- packages/prompts/src/date.ts | 57 ++-- .../test/__snapshots__/date.test.ts.snap | 160 +++++------ packages/prompts/test/date.test.ts | 29 +- 8 files changed, 368 insertions(+), 290 deletions(-) diff --git a/examples/basic/date.ts b/examples/basic/date.ts index 69620b58..739f9f77 100644 --- a/examples/basic/date.ts +++ b/examples/basic/date.ts @@ -2,23 +2,13 @@ import * as p from '@clack/prompts'; import color from 'picocolors'; async function main() { - const result = await p.date({ + const result = (await p.date({ message: color.magenta('Pick a date'), - format: 'YYYY/MM/DD', - }); + format: 'DD/MM/YYYY', + })) as Date; - 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)}`); + const fmt = (d: Date) => d.toISOString().slice(0, 10); + p.outro(`Selected date: ${color.cyan(fmt(result))}`); } main().catch(console.error); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 87de11a8..edd222f3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,7 +2,7 @@ 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 type { DateFormatConfig, DateOptions, DateParts } 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'; diff --git a/packages/core/src/prompts/date.ts b/packages/core/src/prompts/date.ts index 8171e0b5..4b67a64a 100644 --- a/packages/core/src/prompts/date.ts +++ b/packages/core/src/prompts/date.ts @@ -1,77 +1,61 @@ import type { Key } from 'node:readline'; import color from 'picocolors'; +import { settings } from '../utils/settings.js'; 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 { +export interface DateParts { + year: string; + month: string; + day: string; +} + +/** Format config passed from prompts package; core does not define presets */ +export interface DateFormatConfig { segments: SegmentConfig[]; displayTemplate: string; - format: (year: string, month: string, day: string) => string; + format: (parts: DateParts) => 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 }; +function displayToSegmentValues(display: string, config: DateFormatConfig): DateParts { + const blank: DateParts = { year: '____', month: '__', day: '__' }; + for (const seg of config.segments) { + blank[seg.type] = display.slice(seg.start, seg.start + seg.len); + } + return blank; } -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 segmentValuesToParsed(parts: DateParts): { year: number; month: number; day: number } { + const val = (s: string) => Number.parseInt((s || '0').replace(/_/g, '0'), 10) || 0; + return { + year: val(parts.year), + month: val(parts.month), + day: val(parts.day), + }; +} function parseDisplayString( display: string, - config: FormatConfig + config: DateFormatConfig ): { 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; + return segmentValuesToParsed(displayToSegmentValues(display, config)); } function formatDisplayString( { year, month, day }: { year: number; month: number; day: number }, - config: FormatConfig + config: DateFormatConfig ): 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); + const parts: DateParts = { + year: String(year).padStart(4, '0'), + month: String(month).padStart(2, '0'), + day: String(day).padStart(2, '0'), + }; + return config.format(parts); } function clampSegment( @@ -91,7 +75,7 @@ function clampSegment( return Math.max(1, Math.min(daysInMonth, value || 1)); } -function toISOString(display: string, config: FormatConfig): string | undefined { +function toISOString(display: string, config: DateFormatConfig): 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; @@ -100,62 +84,58 @@ function toISOString(display: string, config: FormatConfig): string | undefined 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')}`; + // Use UTC to avoid timezone shifting the date part of toISOString() + return new Date(Date.UTC(year, month - 1, day)).toISOString().slice(0, 10); } -const MONTH_NAMES = [ - 'January', - 'February', - 'March', - 'April', - 'May', - 'June', - 'July', - 'August', - 'September', - 'October', - 'November', - 'December', -]; - function getSegmentValidationMessage( newDisplay: string, seg: SegmentConfig, - config: FormatConfig + config: DateFormatConfig ): 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'; + return settings.date.messages.invalidMonth; } 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}`; + const monthName = + month >= 1 && month <= 12 ? settings.date.monthNames[month - 1] : 'this month'; + return settings.date.messages.invalidDay(daysInMonth, monthName); } } return undefined; } -export interface DateOptions extends PromptOptions { - format?: DateFormat; - defaultValue?: string | Date; +export interface DateOptions extends PromptOptions { + formatConfig: DateFormatConfig; + defaultValue?: Date; + initialValue?: Date; } -export default class DatePrompt extends Prompt { - #format: DateFormat; - #config: FormatConfig; +export default class DatePrompt extends Prompt { + #config: DateFormatConfig; + /** Segment values as source of truth; display derived from this */ + #segmentValues: DateParts; /** Inline validation message shown beneath input during editing (e.g. "There are only 12 months") */ inlineError = ''; + #refreshFromSegmentValues() { + const display = this.#config.format(this.#segmentValues); + this._setUserInput(display); + const iso = toISOString(display, this.#config); + this._setValue(iso ? new Date(iso) : undefined); + } + get cursor() { return this._cursor; } get userInputWithCursor() { - const userInput = this.userInput || this.#config.displayTemplate; + const userInput = this.#config.format(this.#segmentValues); const sep = (ch: string) => (ch === '/' ? color.gray(ch) : ch); if (this.state === 'submit') { return userInput; // plain string for programmatic use (no ANSI) @@ -176,9 +156,10 @@ export default class DatePrompt extends Prompt { } constructor(opts: DateOptions) { + const config = opts.formatConfig; const initialDisplay = DatePrompt.#toDisplayString( - opts.initialValue ?? opts.defaultValue ?? opts.initialUserInput, - opts.format ?? 'YYYY/MM/DD' + opts.initialValue ?? opts.defaultValue ?? (opts as { initialUserInput?: string }).initialUserInput, + config ); super( { @@ -187,22 +168,21 @@ export default class DatePrompt extends Prompt { }, false ); - this.#format = opts.format ?? 'YYYY/MM/DD'; - this.#config = FORMAT_CONFIGS[this.#format]; + this.#config = config; + this.#segmentValues = displayToSegmentValues(initialDisplay, config); 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); + const iso = toISOString(initialDisplay, this.#config); + this._setValue(iso ? new Date(iso) : 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]; + static #toDisplayString(value: Date | string | undefined, config: DateFormatConfig): string { if (!value) return config.displayTemplate; if (value instanceof Date) { return formatDisplayString( @@ -214,16 +194,18 @@ export default class DatePrompt extends Prompt { 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 - ); + if (typeof value === 'string') { + 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; } @@ -273,10 +255,7 @@ export default class DatePrompt extends Prompt { } if (key === 'up' || key === 'down') { - const { year, month, day } = parseDisplayString( - this.userInput || this.#config.displayTemplate, - this.#config - ); + const { year, month, day } = segmentValuesToParsed(this.#segmentValues); const seg = ctx.segment; const delta = key === 'up' ? 1 : -1; @@ -292,12 +271,12 @@ export default class DatePrompt extends Prompt { 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); + this.#segmentValues = { + year: String(newYear).padStart(4, '0'), + month: String(newMonth).padStart(2, '0'), + day: String(newDay).padStart(2, '0'), + }; + this.#refreshFromSegmentValues(); } } @@ -314,16 +293,13 @@ export default class DatePrompt extends Prompt { 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); + const segmentVal = this.#segmentValues[seg.type]; 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); + this.#segmentValues[seg.type] = '_'.repeat(seg.len); + this.#refreshFromSegmentValues(); + this._cursor = seg.start; return; } @@ -331,21 +307,19 @@ export default class DatePrompt extends Prompt { 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); + const segmentDisplay = this.#segmentValues[seg.type]; - // 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 newDisplay = this.#config.format({ + ...this.#segmentValues, + [seg.type]: newSegmentVal, + }); const validationMsg = getSegmentValidationMessage(newDisplay, seg, this.#config); if (validationMsg) { this.inlineError = validationMsg; @@ -354,51 +328,35 @@ export default class DatePrompt extends Prompt { } this.inlineError = ''; + this.#segmentValues[seg.type] = newSegmentVal; + const newDisplay = this.#config.format(this.#segmentValues); 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); + const { year, month, day } = segmentValuesToParsed(this.#segmentValues); + this.#segmentValues = { + year: String(clampSegment(year, 'year', { year, month })).padStart(4, '0'), + month: String(clampSegment(month, 'month', { year, month })).padStart(2, '0'), + day: String(clampSegment(day, 'day', { year, month })).padStart(2, '0'), + }; } + this.#refreshFromSegmentValues(); - // 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 + const wasFilling = firstBlank >= 0; 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 display = this.#config.format(this.#segmentValues); 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; - } + this.value = iso ? new Date(iso) : opts.defaultValue ?? undefined; } } diff --git a/packages/core/src/utils/settings.ts b/packages/core/src/utils/settings.ts index 7a2d880a..d49f15d6 100644 --- a/packages/core/src/utils/settings.ts +++ b/packages/core/src/utils/settings.ts @@ -1,6 +1,11 @@ const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const; export type Action = (typeof actions)[number]; +const DEFAULT_MONTH_NAMES = [ + 'January', 'February', 'March', 'April', 'May', 'June', + 'July', 'August', 'September', 'October', 'November', 'December', +]; + /** Global settings for Clack programs, stored in memory */ interface InternalClackSettings { actions: Set; @@ -10,6 +15,13 @@ interface InternalClackSettings { error: string; }; withGuide: boolean; + date: { + monthNames: string[]; + messages: { + invalidMonth: string; + invalidDay: (days: number, month: string) => string; + }; + }; } export const settings: InternalClackSettings = { @@ -29,6 +41,13 @@ export const settings: InternalClackSettings = { error: 'Something went wrong', }, withGuide: true, + date: { + monthNames: [...DEFAULT_MONTH_NAMES], + messages: { + invalidMonth: 'There are only 12 months in a year', + invalidDay: (days, month) => `There are only ${days} days in ${month}`, + }, + }, }; export interface ClackSettings { @@ -58,6 +77,20 @@ export interface ClackSettings { }; withGuide?: boolean; + + /** + * Date prompt localization + */ + date?: { + /** Month names for validation messages (January, February, ...) */ + monthNames?: string[]; + messages?: { + /** Shown when month > 12 */ + invalidMonth?: string; + /** (days, monthName) => message for invalid day */ + invalidDay?: (days: number, month: string) => string; + }; + }; } export function updateSettings(updates: ClackSettings) { @@ -89,6 +122,21 @@ export function updateSettings(updates: ClackSettings) { if (updates.withGuide !== undefined) { settings.withGuide = updates.withGuide !== false; } + + if (updates.date !== undefined) { + const date = updates.date; + if (date.monthNames !== undefined) { + settings.date.monthNames = [...date.monthNames]; + } + if (date.messages !== undefined) { + if (date.messages.invalidMonth !== undefined) { + settings.date.messages.invalidMonth = date.messages.invalidMonth; + } + if (date.messages.invalidDay !== undefined) { + settings.date.messages.invalidDay = date.messages.invalidDay; + } + } + } } /** diff --git a/packages/core/test/prompts/date.test.ts b/packages/core/test/prompts/date.test.ts index 72d1a225..b76f3422 100644 --- a/packages/core/test/prompts/date.test.ts +++ b/packages/core/test/prompts/date.test.ts @@ -1,11 +1,45 @@ import color from 'picocolors'; import { cursor } from 'sisteransi'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import type { DateFormatConfig, DateParts } from '../../src/prompts/date.js'; 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'; +function buildFormatConfig( + format: (p: DateParts) => string, + types: ('year' | 'month' | 'day')[] +): DateFormatConfig { + const displayTemplate = format({ year: '____', month: '__', day: '__' }); + const parts = displayTemplate.split('/'); + let start = 0; + const segments = parts.map((part, i) => { + const seg = { type: types[i], start, len: part.length }; + start += part.length + 1; + return seg; + }); + return { segments, displayTemplate, format }; +} + +const YYYY_MM_DD = buildFormatConfig( + (p) => `${p.year}/${p.month}/${p.day}`, + ['year', 'month', 'day'] +); +const MM_DD_YYYY = buildFormatConfig( + (p) => `${p.month}/${p.day}/${p.year}`, + ['month', 'day', 'year'] +); +const DD_MM_YYYY = buildFormatConfig( + (p) => `${p.day}/${p.month}/${p.year}`, + ['day', 'month', 'year'] +); + +const d = (iso: string) => { + const [y, m, day] = iso.slice(0, 10).split('-').map(Number); + return new Date(y, m - 1, day); +}; + describe('DatePrompt', () => { let input: MockReadable; let output: MockWritable; @@ -24,6 +58,7 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', + formatConfig: YYYY_MM_DD, }); instance.prompt(); expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); @@ -34,11 +69,13 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - initialValue: '2025-01-15', + formatConfig: YYYY_MM_DD, + initialValue: d('2025-01-15'), }); instance.prompt(); expect(instance.userInput).to.equal('2025/01/15'); - expect(instance.value).to.equal('2025-01-15'); + expect(instance.value).toBeInstanceOf(Date); + expect(instance.value!.toISOString().slice(0, 10)).to.equal('2025-01-15'); }); test('left/right navigates between segments', () => { @@ -46,7 +83,8 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - initialValue: '2025-01-15', + formatConfig: YYYY_MM_DD, + initialValue: d('2025-01-15'), }); instance.prompt(); expect(instance.cursor).to.equal(0); // year start @@ -68,7 +106,8 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - initialValue: '2025-01-15', + formatConfig: YYYY_MM_DD, + initialValue: d('2025-01-15'), }); instance.prompt(); for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month @@ -83,7 +122,8 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - initialValue: '2025-01-15', + formatConfig: YYYY_MM_DD, + initialValue: d('2025-01-15'), }); instance.prompt(); expect(instance.cursor).to.equal(0); // year start @@ -101,7 +141,8 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - initialValue: '2025-12-21', + formatConfig: YYYY_MM_DD, + initialValue: d('2025-12-21'), }); instance.prompt(); expect(instance.userInput).to.equal('2025/12/21'); @@ -117,6 +158,7 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', + formatConfig: YYYY_MM_DD, }); instance.prompt(); // Type "2" to get "2___" @@ -137,6 +179,7 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', + formatConfig: YYYY_MM_DD, }); instance.prompt(); // Type year 2025 - left-to-right, jumps to month when year complete @@ -152,12 +195,14 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - initialValue: '2025-01-31', + formatConfig: YYYY_MM_DD, + initialValue: d('2025-01-31'), }); const resultPromise = instance.prompt(); input.emit('keypress', undefined, { name: 'return' }); const result = await resultPromise; - expect(result).to.equal('2025-01-31'); + expect(result).toBeInstanceOf(Date); + expect((result as Date).toISOString().slice(0, 10)).to.equal('2025-01-31'); }); test('can cancel', async () => { @@ -165,7 +210,8 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - initialValue: '2025-01-15', + formatConfig: YYYY_MM_DD, + initialValue: d('2025-01-15'), }); const resultPromise = instance.prompt(); input.emit('keypress', 'escape', { name: 'escape' }); @@ -178,12 +224,14 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - defaultValue: '2025-06-15', + formatConfig: YYYY_MM_DD, + defaultValue: d('2025-06-15'), }); const resultPromise = instance.prompt(); input.emit('keypress', undefined, { name: 'return' }); const result = await resultPromise; - expect(result).to.equal('2025-06-15'); + expect(result).toBeInstanceOf(Date); + expect((result as Date).toISOString().slice(0, 10)).to.equal('2025-06-15'); }); test('supports MM/DD/YYYY format', () => { @@ -191,8 +239,8 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - format: 'MM/DD/YYYY', - initialValue: '2025-01-15', + formatConfig: MM_DD_YYYY, + initialValue: d('2025-01-15'), }); instance.prompt(); expect(instance.userInput).to.equal('01/15/2025'); @@ -203,7 +251,8 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - initialValue: '2025-01-15', // month is 01 + formatConfig: YYYY_MM_DD, + initialValue: d('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) @@ -217,7 +266,8 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - initialValue: '2025-01-15', // January has 31 days + formatConfig: YYYY_MM_DD, + initialValue: d('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) @@ -232,8 +282,8 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - format: 'DD/MM/YYYY', - initialValue: '2025-01-15', + formatConfig: DD_MM_YYYY, + initialValue: d('2025-01-15'), }); instance.prompt(); expect(instance.userInput).to.equal('15/01/2025'); @@ -245,7 +295,8 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - initialValue: '2025-01-15', + formatConfig: YYYY_MM_DD, + initialValue: d('2025-01-15'), }); instance.prompt(); for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month @@ -258,7 +309,8 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - initialValue: '2025-01-15', + formatConfig: YYYY_MM_DD, + initialValue: d('2025-01-15'), }); instance.prompt(); input.emit('keypress', undefined, { name: 'return' }); diff --git a/packages/prompts/src/date.ts b/packages/prompts/src/date.ts index 3d017418..ee8c313d 100644 --- a/packages/prompts/src/date.ts +++ b/packages/prompts/src/date.ts @@ -1,35 +1,55 @@ import { DatePrompt, settings } from '@clack/core'; -import type { DateFormat } from '@clack/core'; +import type { DateFormatConfig, DateParts } from '@clack/core'; import color from 'picocolors'; import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; +export type DateFormat = 'YYYY/MM/DD' | 'MM/DD/YYYY' | 'DD/MM/YYYY'; + +const formatters: Record string> = { + 'YYYY/MM/DD': (p) => `${p.year}/${p.month}/${p.day}`, + 'MM/DD/YYYY': (p) => `${p.month}/${p.day}/${p.year}`, + 'DD/MM/YYYY': (p) => `${p.day}/${p.month}/${p.year}`, +}; + +function buildFormatConfig(format: DateFormat): DateFormatConfig { + const formatFn = formatters[format]; + const displayTemplate = formatFn({ year: '____', month: '__', day: '__' }); + // Derive segment order from formatter output (e.g. "Y/M/D" → [year, month, day]) + const marker = formatFn({ year: 'Y', month: 'M', day: 'D' }); + const typeMap = { Y: 'year' as const, M: 'month' as const, D: 'day' as const }; + const types = marker.split('/').map((c) => typeMap[c as keyof typeof typeMap]); + const parts = displayTemplate.split('/'); + let start = 0; + const segments = parts.map((part, i) => { + const seg = { type: types[i], start, len: part.length }; + start += part.length + 1; + return seg; + }); + return { segments, displayTemplate, format: formatFn }; +} + export interface DateOptions extends CommonOptions { message: string; format?: DateFormat; - defaultValue?: string | Date; - initialValue?: string | Date; - validate?: (value: string | undefined) => string | Error | undefined; + defaultValue?: Date; + initialValue?: Date; + validate?: (value: Date | undefined) => string | Error | undefined; } export const date = (opts: DateOptions) => { const validate = opts.validate; + const formatConfig = buildFormatConfig(opts.format ?? 'YYYY/MM/DD'); return new DatePrompt({ - format: opts.format ?? 'YYYY/MM/DD', + formatConfig, defaultValue: opts.defaultValue, initialValue: opts.initialValue, - validate(value) { - if (value === undefined || value === '') { - if (opts.defaultValue !== undefined) { - return undefined; - } - if (validate) { - return validate(value); - } + validate(value: Date | undefined) { + if (value === undefined) { + if (opts.defaultValue !== undefined) return undefined; + if (validate) return validate(value); return 'Please enter a valid date'; } - if (validate) { - return validate(value); - } + if (validate) return validate(value); return undefined; }, signal: opts.signal, @@ -40,7 +60,8 @@ export const date = (opts: DateOptions) => { 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 ?? ''; + const value = + this.value instanceof Date ? this.value.toISOString().slice(0, 10) : ''; switch (this.state) { case 'error': { @@ -72,5 +93,5 @@ export const date = (opts: DateOptions) => { } } }, - }).prompt() as Promise; + }).prompt() as Promise; }; diff --git a/packages/prompts/test/__snapshots__/date.test.ts.snap b/packages/prompts/test/__snapshots__/date.test.ts.snap index 44f06716..e26a7b51 100644 --- a/packages/prompts/test/__snapshots__/date.test.ts.snap +++ b/packages/prompts/test/__snapshots__/date.test.ts.snap @@ -3,16 +3,16 @@ exports[`date (isCI = false) > can cancel 1`] = ` [ "", - "│ -◆ Pick a date -│  ___/__/__ -└ + "│ +◆ Pick a date +│ ___/__/__ +└ ", "", "", "", - "■ Pick a date -│", + "■ Pick a date +│", " ", "", @@ -22,16 +22,16 @@ exports[`date (isCI = false) > can cancel 1`] = ` exports[`date (isCI = false) > defaultValue used when empty submit 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/12/25 -└ + "│ +◆ Pick a date +│ 2025/12/25 +└ ", "", "", "", - "◇ Pick a date -│ 2025-12-25", + "◇ Pick a date +│ 2025-12-25", " ", "", @@ -41,16 +41,16 @@ exports[`date (isCI = false) > defaultValue used when empty submit 1`] = ` exports[`date (isCI = false) > renders initial value 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/01/15 -└ + "│ +◆ Pick a date +│ 2025/01/15 +└ ", "", "", "", - "◇ Pick a date -│ 2025-01-15", + "◇ Pick a date +│ 2025-01-15", " ", "", @@ -60,16 +60,16 @@ exports[`date (isCI = false) > renders initial value 1`] = ` exports[`date (isCI = false) > renders message 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/01/15 -└ + "│ +◆ Pick a date +│ 2025/01/15 +└ ", "", "", "", - "◇ Pick a date -│ 2025-01-15", + "◇ Pick a date +│ 2025-01-15", " ", "", @@ -79,16 +79,16 @@ exports[`date (isCI = false) > renders message 1`] = ` exports[`date (isCI = false) > renders submitted value 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/06/15 -└ + "│ +◆ Pick a date +│ 2025/06/15 +└ ", "", "", "", - "◇ Pick a date -│ 2025-06-15", + "◇ Pick a date +│ 2025-06-15", " ", "", @@ -98,16 +98,16 @@ exports[`date (isCI = false) > renders submitted value 1`] = ` exports[`date (isCI = false) > supports MM/DD/YYYY format 1`] = ` [ "", - "│ -◆ Pick a date -│ 01/15/2025 -└ + "│ +◆ Pick a date +│ 01/15/2025 +└ ", "", "", "", - "◇ Pick a date -│ 2025-01-15", + "◇ Pick a date +│ 2025-01-15", " ", "", @@ -117,14 +117,14 @@ exports[`date (isCI = false) > supports MM/DD/YYYY format 1`] = ` exports[`date (isCI = false) > withGuide: false removes guide 1`] = ` [ "", - "◆ Pick a date -2025/01/15 + "◆ Pick a date +2025/01/15 ", "", "", - "◇ Pick a date - 2025-01-15", + "◇ Pick a date + 2025-01-15", " ", "", @@ -134,16 +134,16 @@ exports[`date (isCI = false) > withGuide: false removes guide 1`] = ` exports[`date (isCI = true) > can cancel 1`] = ` [ "", - "│ -◆ Pick a date -│  ___/__/__ -└ + "│ +◆ Pick a date +│ ___/__/__ +└ ", "", "", "", - "■ Pick a date -│", + "■ Pick a date +│", " ", "", @@ -153,16 +153,16 @@ exports[`date (isCI = true) > can cancel 1`] = ` exports[`date (isCI = true) > defaultValue used when empty submit 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/12/25 -└ + "│ +◆ Pick a date +│ 2025/12/25 +└ ", "", "", "", - "◇ Pick a date -│ 2025-12-25", + "◇ Pick a date +│ 2025-12-25", " ", "", @@ -172,16 +172,16 @@ exports[`date (isCI = true) > defaultValue used when empty submit 1`] = ` exports[`date (isCI = true) > renders initial value 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/01/15 -└ + "│ +◆ Pick a date +│ 2025/01/15 +└ ", "", "", "", - "◇ Pick a date -│ 2025-01-15", + "◇ Pick a date +│ 2025-01-15", " ", "", @@ -191,16 +191,16 @@ exports[`date (isCI = true) > renders initial value 1`] = ` exports[`date (isCI = true) > renders message 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/01/15 -└ + "│ +◆ Pick a date +│ 2025/01/15 +└ ", "", "", "", - "◇ Pick a date -│ 2025-01-15", + "◇ Pick a date +│ 2025-01-15", " ", "", @@ -210,16 +210,16 @@ exports[`date (isCI = true) > renders message 1`] = ` exports[`date (isCI = true) > renders submitted value 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/06/15 -└ + "│ +◆ Pick a date +│ 2025/06/15 +└ ", "", "", "", - "◇ Pick a date -│ 2025-06-15", + "◇ Pick a date +│ 2025-06-15", " ", "", @@ -229,16 +229,16 @@ exports[`date (isCI = true) > renders submitted value 1`] = ` exports[`date (isCI = true) > supports MM/DD/YYYY format 1`] = ` [ "", - "│ -◆ Pick a date -│ 01/15/2025 -└ + "│ +◆ Pick a date +│ 01/15/2025 +└ ", "", "", "", - "◇ Pick a date -│ 2025-01-15", + "◇ Pick a date +│ 2025-01-15", " ", "", @@ -248,14 +248,14 @@ exports[`date (isCI = true) > supports MM/DD/YYYY format 1`] = ` exports[`date (isCI = true) > withGuide: false removes guide 1`] = ` [ "", - "◆ Pick a date -2025/01/15 + "◆ Pick a date +2025/01/15 ", "", "", - "◇ 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 index d8ded27a..53491d56 100644 --- a/packages/prompts/test/date.test.ts +++ b/packages/prompts/test/date.test.ts @@ -3,6 +3,11 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi import * as prompts from '../src/index.js'; import { MockReadable, MockWritable } from './test-utils.js'; +const d = (iso: string) => { + const [y, m, day] = iso.slice(0, 10).split('-').map(Number); + return new Date(y, m - 1, day); +}; + describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { let originalCI: string | undefined; let output: MockWritable; @@ -30,7 +35,7 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { test('renders message', async () => { const result = prompts.date({ message: 'Pick a date', - initialValue: '2025-01-15', + initialValue: d('2025-01-15'), input, output, }); @@ -45,7 +50,7 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { test('renders initial value', async () => { const result = prompts.date({ message: 'Pick a date', - initialValue: '2025-01-15', + initialValue: d('2025-01-15'), input, output, }); @@ -54,7 +59,8 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { const value = await result; - expect(value).toBe('2025-01-15'); + expect(value).toBeInstanceOf(Date); + expect((value as Date).toISOString().slice(0, 10)).toBe('2025-01-15'); expect(output.buffer).toMatchSnapshot(); }); @@ -76,7 +82,7 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { test('renders submitted value', async () => { const result = prompts.date({ message: 'Pick a date', - initialValue: '2025-06-15', + initialValue: d('2025-06-15'), input, output, }); @@ -85,14 +91,15 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { const value = await result; - expect(value).toBe('2025-06-15'); + expect(value).toBeInstanceOf(Date); + expect((value as Date).toISOString().slice(0, 10)).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', + defaultValue: d('2025-12-25'), input, output, }); @@ -101,7 +108,8 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { const value = await result; - expect(value).toBe('2025-12-25'); + expect(value).toBeInstanceOf(Date); + expect((value as Date).toISOString().slice(0, 10)).toBe('2025-12-25'); expect(output.buffer).toMatchSnapshot(); }); @@ -109,7 +117,7 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { const result = prompts.date({ message: 'Pick a date', withGuide: false, - initialValue: '2025-01-15', + initialValue: d('2025-01-15'), input, output, }); @@ -125,7 +133,7 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { const result = prompts.date({ message: 'Pick a date', format: 'MM/DD/YYYY', - initialValue: '2025-01-15', + initialValue: d('2025-01-15'), input, output, }); @@ -134,7 +142,8 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { const value = await result; - expect(value).toBe('2025-01-15'); + expect(value).toBeInstanceOf(Date); + expect((value as Date).toISOString().slice(0, 10)).toBe('2025-01-15'); expect(output.buffer).toMatchSnapshot(); }); }); From ad33564e38915374fd2133f591a92810fee0c57c Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Tue, 3 Feb 2026 19:44:36 -0600 Subject: [PATCH 4/8] chore: update date prompt snapshots --- .../test/__snapshots__/date.test.ts.snap | 160 +++++++++--------- 1 file changed, 80 insertions(+), 80 deletions(-) diff --git a/packages/prompts/test/__snapshots__/date.test.ts.snap b/packages/prompts/test/__snapshots__/date.test.ts.snap index e26a7b51..44f06716 100644 --- a/packages/prompts/test/__snapshots__/date.test.ts.snap +++ b/packages/prompts/test/__snapshots__/date.test.ts.snap @@ -3,16 +3,16 @@ exports[`date (isCI = false) > can cancel 1`] = ` [ "", - "│ -◆ Pick a date -│ ___/__/__ -└ + "│ +◆ Pick a date +│  ___/__/__ +└ ", "", "", "", - "■ Pick a date -│", + "■ Pick a date +│", " ", "", @@ -22,16 +22,16 @@ exports[`date (isCI = false) > can cancel 1`] = ` exports[`date (isCI = false) > defaultValue used when empty submit 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/12/25 -└ + "│ +◆ Pick a date +│ 2025/12/25 +└ ", "", "", "", - "◇ Pick a date -│ 2025-12-25", + "◇ Pick a date +│ 2025-12-25", " ", "", @@ -41,16 +41,16 @@ exports[`date (isCI = false) > defaultValue used when empty submit 1`] = ` exports[`date (isCI = false) > renders initial value 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/01/15 -└ + "│ +◆ Pick a date +│ 2025/01/15 +└ ", "", "", "", - "◇ Pick a date -│ 2025-01-15", + "◇ Pick a date +│ 2025-01-15", " ", "", @@ -60,16 +60,16 @@ exports[`date (isCI = false) > renders initial value 1`] = ` exports[`date (isCI = false) > renders message 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/01/15 -└ + "│ +◆ Pick a date +│ 2025/01/15 +└ ", "", "", "", - "◇ Pick a date -│ 2025-01-15", + "◇ Pick a date +│ 2025-01-15", " ", "", @@ -79,16 +79,16 @@ exports[`date (isCI = false) > renders message 1`] = ` exports[`date (isCI = false) > renders submitted value 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/06/15 -└ + "│ +◆ Pick a date +│ 2025/06/15 +└ ", "", "", "", - "◇ Pick a date -│ 2025-06-15", + "◇ Pick a date +│ 2025-06-15", " ", "", @@ -98,16 +98,16 @@ exports[`date (isCI = false) > renders submitted value 1`] = ` exports[`date (isCI = false) > supports MM/DD/YYYY format 1`] = ` [ "", - "│ -◆ Pick a date -│ 01/15/2025 -└ + "│ +◆ Pick a date +│ 01/15/2025 +└ ", "", "", "", - "◇ Pick a date -│ 2025-01-15", + "◇ Pick a date +│ 2025-01-15", " ", "", @@ -117,14 +117,14 @@ exports[`date (isCI = false) > supports MM/DD/YYYY format 1`] = ` exports[`date (isCI = false) > withGuide: false removes guide 1`] = ` [ "", - "◆ Pick a date -2025/01/15 + "◆ Pick a date +2025/01/15 ", "", "", - "◇ Pick a date - 2025-01-15", + "◇ Pick a date + 2025-01-15", " ", "", @@ -134,16 +134,16 @@ exports[`date (isCI = false) > withGuide: false removes guide 1`] = ` exports[`date (isCI = true) > can cancel 1`] = ` [ "", - "│ -◆ Pick a date -│ ___/__/__ -└ + "│ +◆ Pick a date +│  ___/__/__ +└ ", "", "", "", - "■ Pick a date -│", + "■ Pick a date +│", " ", "", @@ -153,16 +153,16 @@ exports[`date (isCI = true) > can cancel 1`] = ` exports[`date (isCI = true) > defaultValue used when empty submit 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/12/25 -└ + "│ +◆ Pick a date +│ 2025/12/25 +└ ", "", "", "", - "◇ Pick a date -│ 2025-12-25", + "◇ Pick a date +│ 2025-12-25", " ", "", @@ -172,16 +172,16 @@ exports[`date (isCI = true) > defaultValue used when empty submit 1`] = ` exports[`date (isCI = true) > renders initial value 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/01/15 -└ + "│ +◆ Pick a date +│ 2025/01/15 +└ ", "", "", "", - "◇ Pick a date -│ 2025-01-15", + "◇ Pick a date +│ 2025-01-15", " ", "", @@ -191,16 +191,16 @@ exports[`date (isCI = true) > renders initial value 1`] = ` exports[`date (isCI = true) > renders message 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/01/15 -└ + "│ +◆ Pick a date +│ 2025/01/15 +└ ", "", "", "", - "◇ Pick a date -│ 2025-01-15", + "◇ Pick a date +│ 2025-01-15", " ", "", @@ -210,16 +210,16 @@ exports[`date (isCI = true) > renders message 1`] = ` exports[`date (isCI = true) > renders submitted value 1`] = ` [ "", - "│ -◆ Pick a date -│ 2025/06/15 -└ + "│ +◆ Pick a date +│ 2025/06/15 +└ ", "", "", "", - "◇ Pick a date -│ 2025-06-15", + "◇ Pick a date +│ 2025-06-15", " ", "", @@ -229,16 +229,16 @@ exports[`date (isCI = true) > renders submitted value 1`] = ` exports[`date (isCI = true) > supports MM/DD/YYYY format 1`] = ` [ "", - "│ -◆ Pick a date -│ 01/15/2025 -└ + "│ +◆ Pick a date +│ 01/15/2025 +└ ", "", "", "", - "◇ Pick a date -│ 2025-01-15", + "◇ Pick a date +│ 2025-01-15", " ", "", @@ -248,14 +248,14 @@ exports[`date (isCI = true) > supports MM/DD/YYYY format 1`] = ` exports[`date (isCI = true) > withGuide: false removes guide 1`] = ` [ "", - "◆ Pick a date -2025/01/15 + "◆ Pick a date +2025/01/15 ", "", "", - "◇ Pick a date - 2025-01-15", + "◇ Pick a date + 2025-01-15", " ", "", From b9206d882ee4063a3698414f1fea4f3886faf0de Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Wed, 4 Feb 2026 00:16:13 -0600 Subject: [PATCH 5/8] feat(date): add min/max, segment hints, keep blank segments on up/down --- examples/basic/date.ts | 9 +- packages/core/src/prompts/date.ts | 139 ++++++++++++++---- packages/core/src/utils/settings.ts | 28 +++- packages/core/test/prompts/date.test.ts | 60 ++++++++ packages/prompts/src/date.ts | 28 +++- .../test/__snapshots__/date.test.ts.snap | 28 ++-- packages/prompts/test/date.test.ts | 22 +++ 7 files changed, 261 insertions(+), 53 deletions(-) diff --git a/examples/basic/date.ts b/examples/basic/date.ts index 739f9f77..ece41f1b 100644 --- a/examples/basic/date.ts +++ b/examples/basic/date.ts @@ -4,9 +4,16 @@ import color from 'picocolors'; async function main() { const result = (await p.date({ message: color.magenta('Pick a date'), - format: 'DD/MM/YYYY', + format: 'YYYY/MM/DD', + minDate: new Date('2025-01-01'), + maxDate: new Date('2025-12-31'), })) as Date; + if (p.isCancel(result)) { + p.cancel('Operation cancelled.'); + process.exit(0); + } + const fmt = (d: Date) => d.toISOString().slice(0, 10); p.outro(`Selected date: ${color.cyan(fmt(result))}`); } diff --git a/packages/core/src/prompts/date.ts b/packages/core/src/prompts/date.ts index 4b67a64a..ca4541f1 100644 --- a/packages/core/src/prompts/date.ts +++ b/packages/core/src/prompts/date.ts @@ -15,11 +15,19 @@ export interface DateParts { day: string; } +const DEFAULT_SEGMENT_LABELS: Record<'year' | 'month' | 'day', string> = { + year: 'yyyy', + month: 'mm', + day: 'dd', +}; + /** Format config passed from prompts package; core does not define presets */ export interface DateFormatConfig { segments: SegmentConfig[]; displayTemplate: string; format: (parts: DateParts) => string; + /** Labels shown when segment is blank (e.g. yyyy, mm, dd). Default: { year: 'yyyy', month: 'mm', day: 'dd' } */ + segmentLabels?: { year: string; month: string; day: string }; } function displayToSegmentValues(display: string, config: DateFormatConfig): DateParts { @@ -75,6 +83,53 @@ function clampSegment( return Math.max(1, Math.min(daysInMonth, value || 1)); } +function datePartsUTC(d: Date): { year: number; month: number; day: number } { + return { + year: d.getUTCFullYear(), + month: d.getUTCMonth() + 1, + day: d.getUTCDate(), + }; +} + +function getSegmentBounds( + type: 'year' | 'month' | 'day', + context: { year: number; month: number; day: number }, + minDate: Date | undefined, + maxDate: Date | undefined +): { min: number; max: number } { + const { year, month } = context; + const minParts = minDate ? datePartsUTC(minDate) : null; + const maxParts = maxDate ? datePartsUTC(maxDate) : null; + + if (type === 'year') { + const min = minParts ? minParts.year : 1000; + const max = maxParts ? maxParts.year : 9999; + return { min, max }; + } + if (type === 'month') { + let min = 1; + let max = 12; + if (minParts && year && year >= minParts.year) { + if (year === minParts.year) min = minParts.month; + } + if (maxParts && year && year <= maxParts.year) { + if (year === maxParts.year) max = maxParts.month; + } + return { min, max }; + } + // day + const daysInMonth = new Date(year || 2000, month || 1, 0).getDate(); + let min = 1; + let max = daysInMonth; + if (minParts && year && month && year === minParts.year && month === minParts.month) { + min = minParts.day; + } + if (maxParts && year && month && year === maxParts.year && month === maxParts.month) { + max = maxParts.day; + } + return { min, max }; +} + function toISOString(display: string, config: DateFormatConfig): string | undefined { const { year, month, day } = parseDisplayString(display, config); if (!year || year < 1000 || year > 9999) return undefined; @@ -113,12 +168,15 @@ export interface DateOptions extends PromptOptions { formatConfig: DateFormatConfig; defaultValue?: Date; initialValue?: Date; + minDate?: Date; + maxDate?: Date; } export default class DatePrompt extends Prompt { #config: DateFormatConfig; - /** Segment values as source of truth; display derived from this */ #segmentValues: DateParts; + #minDate: Date | undefined; + #maxDate: Date | undefined; /** Inline validation message shown beneath input during editing (e.g. "There are only 12 months") */ inlineError = ''; @@ -135,30 +193,51 @@ export default class DatePrompt extends Prompt { } get userInputWithCursor() { - const userInput = this.#config.format(this.#segmentValues); - const sep = (ch: string) => (ch === '/' ? color.gray(ch) : ch); + const labels = this.#config.segmentLabels ?? DEFAULT_SEGMENT_LABELS; + const sep = color.gray('/'); if (this.state === 'submit') { - return userInput; // plain string for programmatic use (no ANSI) + return this.#config.format(this.#segmentValues); } - 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); + const parts: string[] = []; + for (let i = 0; i < this.#config.segments.length; i++) { + if (i > 0) parts.push(sep); + const seg = this.#config.segments[i]; + const val = this.#segmentValues[seg.type]; + const isBlank = !val || val.replace(/_/g, '') === ''; + if (isBlank) { + const label = labels[seg.type]; + const cursorInThis = this._cursor >= seg.start && this._cursor < seg.start + seg.len; + if (cursorInThis) { + const posInSeg = this._cursor - seg.start; + for (let j = 0; j < label.length; j++) { + parts.push(j === posInSeg ? color.inverse(' ') : color.dim(label[j])); + } + } else { + parts.push(color.dim(label)); + } } else { - result += sep(ch); // keep '_' as-is for placeholders, grey '/' + for (let j = 0; j < val.length; j++) { + const globalIdx = seg.start + j; + if (globalIdx === this._cursor) { + parts.push(val[j] === '_' ? color.inverse(' ') : color.inverse(val[j])); + } else { + parts.push(val[j] === '_' ? color.dim(' ') : val[j]); + } + } } } - if (this._cursor >= userInput.length) { - result += '█'; + if (this._cursor >= this.#config.displayTemplate.length) { + parts.push('█'); } - return result; + return parts.join(''); } constructor(opts: DateOptions) { const config = opts.formatConfig; const initialDisplay = DatePrompt.#toDisplayString( - opts.initialValue ?? opts.defaultValue ?? (opts as { initialUserInput?: string }).initialUserInput, + opts.initialValue ?? + opts.defaultValue ?? + (opts as { initialUserInput?: string }).initialUserInput, config ); super( @@ -170,6 +249,8 @@ export default class DatePrompt extends Prompt { ); this.#config = config; this.#segmentValues = displayToSegmentValues(initialDisplay, config); + this.#minDate = opts.minDate; + this.#maxDate = opts.maxDate; this._setUserInput(initialDisplay); const firstSeg = this.#config.segments[0]; @@ -255,27 +336,25 @@ export default class DatePrompt extends Prompt { } if (key === 'up' || key === 'down') { - const { year, month, day } = segmentValuesToParsed(this.#segmentValues); + const context = segmentValuesToParsed(this.#segmentValues); const seg = ctx.segment; - const delta = key === 'up' ? 1 : -1; + const raw = this.#segmentValues[seg.type]; + const isBlank = !raw || raw.replace(/_/g, '') === ''; + const num = Number.parseInt((raw || '0').replace(/_/g, '0'), 10) || 0; - let newYear = year; - let newMonth = month; - let newDay = day; + const bounds = getSegmentBounds(seg.type, context, this.#minDate, this.#maxDate); - if (seg.type === 'year') { - newYear = clampSegment(year + delta, 'year', { year, month }); - } else if (seg.type === 'month') { - newMonth = clampSegment(month + delta, 'month', { year, month }); + let newNum: number; + if (isBlank) { + newNum = key === 'up' ? bounds.min : bounds.max; } else { - newDay = clampSegment(day + delta, 'day', { year, month }); + const delta = key === 'up' ? 1 : -1; + newNum = Math.max(bounds.min, Math.min(bounds.max, num + delta)); } - this.#segmentValues = { - year: String(newYear).padStart(4, '0'), - month: String(newMonth).padStart(2, '0'), - day: String(newDay).padStart(2, '0'), - }; + const len = seg.len; + const newSegmentValue = String(newNum).padStart(len, '0'); + this.#segmentValues = { ...this.#segmentValues, [seg.type]: newSegmentValue }; this.#refreshFromSegmentValues(); } } @@ -357,6 +436,6 @@ export default class DatePrompt extends Prompt { #onFinalize(opts: DateOptions) { const display = this.#config.format(this.#segmentValues); const iso = toISOString(display, this.#config); - this.value = iso ? new Date(iso) : opts.defaultValue ?? undefined; + this.value = iso ? new Date(iso) : (opts.defaultValue ?? undefined); } } diff --git a/packages/core/src/utils/settings.ts b/packages/core/src/utils/settings.ts index d49f15d6..b8394d27 100644 --- a/packages/core/src/utils/settings.ts +++ b/packages/core/src/utils/settings.ts @@ -2,8 +2,18 @@ const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as c export type Action = (typeof actions)[number]; const DEFAULT_MONTH_NAMES = [ - 'January', 'February', 'March', 'April', 'May', 'June', - 'July', 'August', 'September', 'October', 'November', 'December', + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', ]; /** Global settings for Clack programs, stored in memory */ @@ -20,6 +30,8 @@ interface InternalClackSettings { messages: { invalidMonth: string; invalidDay: (days: number, month: string) => string; + afterMin: (min: Date) => string; + beforeMax: (max: Date) => string; }; }; } @@ -46,6 +58,8 @@ export const settings: InternalClackSettings = { messages: { invalidMonth: 'There are only 12 months in a year', invalidDay: (days, month) => `There are only ${days} days in ${month}`, + afterMin: (min) => `Date must be on or after ${min.toISOString().slice(0, 10)}`, + beforeMax: (max) => `Date must be on or before ${max.toISOString().slice(0, 10)}`, }, }, }; @@ -89,6 +103,10 @@ export interface ClackSettings { invalidMonth?: string; /** (days, monthName) => message for invalid day */ invalidDay?: (days: number, month: string) => string; + /** (min) => message when date is before minDate */ + afterMin?: (min: Date) => string; + /** (max) => message when date is after maxDate */ + beforeMax?: (max: Date) => string; }; }; } @@ -135,6 +153,12 @@ export function updateSettings(updates: ClackSettings) { if (date.messages.invalidDay !== undefined) { settings.date.messages.invalidDay = date.messages.invalidDay; } + if (date.messages.afterMin !== undefined) { + settings.date.messages.afterMin = date.messages.afterMin; + } + if (date.messages.beforeMax !== undefined) { + settings.date.messages.beforeMax = date.messages.beforeMax; + } } } } diff --git a/packages/core/test/prompts/date.test.ts b/packages/core/test/prompts/date.test.ts index b76f3422..9cd8ce24 100644 --- a/packages/core/test/prompts/date.test.ts +++ b/packages/core/test/prompts/date.test.ts @@ -117,6 +117,66 @@ describe('DatePrompt', () => { expect(instance.userInput).to.equal('2025/01/15'); }); + test('up/down on one segment leaves other segments blank', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + formatConfig: YYYY_MM_DD, + }); + instance.prompt(); + expect(instance.userInput).to.equal('____/__/__'); + input.emit('keypress', undefined, { name: 'up' }); // up on year (first segment) + expect(instance.userInput).to.equal('1000/__/__'); + input.emit('keypress', undefined, { name: 'right' }); + input.emit('keypress', undefined, { name: 'right' }); + input.emit('keypress', undefined, { name: 'right' }); + input.emit('keypress', undefined, { name: 'right' }); // move to month + input.emit('keypress', undefined, { name: 'up' }); + expect(instance.userInput).to.equal('1000/01/__'); + }); + + test('with minDate/maxDate, up on blank segment starts at min', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + formatConfig: YYYY_MM_DD, + minDate: d('2025-03-10'), + maxDate: d('2025-11-20'), + }); + instance.prompt(); + expect(instance.userInput).to.equal('____/__/__'); + input.emit('keypress', undefined, { name: 'up' }); + expect(instance.userInput).to.equal('2025/__/__'); + for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); + input.emit('keypress', undefined, { name: 'up' }); + expect(instance.userInput).to.equal('2025/03/__'); + for (let i = 0; i < 2; i++) input.emit('keypress', undefined, { name: 'right' }); + input.emit('keypress', undefined, { name: 'up' }); + expect(instance.userInput).to.equal('2025/03/10'); + }); + + test('with minDate/maxDate, down on blank segment starts at max', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + formatConfig: YYYY_MM_DD, + minDate: d('2025-03-10'), + maxDate: d('2025-11-20'), + }); + instance.prompt(); + input.emit('keypress', undefined, { name: 'down' }); + expect(instance.userInput).to.equal('2025/__/__'); + for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); + input.emit('keypress', undefined, { name: 'down' }); + expect(instance.userInput).to.equal('2025/11/__'); + for (let i = 0; i < 2; i++) input.emit('keypress', undefined, { name: 'right' }); + input.emit('keypress', undefined, { name: 'down' }); + expect(instance.userInput).to.equal('2025/11/20'); + }); + test('digit-by-digit editing from left to right', () => { const instance = new DatePrompt({ input, diff --git a/packages/prompts/src/date.ts b/packages/prompts/src/date.ts index ee8c313d..55f85cab 100644 --- a/packages/prompts/src/date.ts +++ b/packages/prompts/src/date.ts @@ -1,5 +1,5 @@ -import { DatePrompt, settings } from '@clack/core'; import type { DateFormatConfig, DateParts } from '@clack/core'; +import { DatePrompt, settings } from '@clack/core'; import color from 'picocolors'; import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; @@ -33,6 +33,8 @@ export interface DateOptions extends CommonOptions { format?: DateFormat; defaultValue?: Date; initialValue?: Date; + minDate?: Date; + maxDate?: Date; validate?: (value: Date | undefined) => string | Error | undefined; } @@ -43,12 +45,21 @@ export const date = (opts: DateOptions) => { formatConfig, defaultValue: opts.defaultValue, initialValue: opts.initialValue, + minDate: opts.minDate, + maxDate: opts.maxDate, validate(value: Date | undefined) { if (value === undefined) { if (opts.defaultValue !== undefined) return undefined; if (validate) return validate(value); return 'Please enter a valid date'; } + const dateOnly = (d: Date) => d.toISOString().slice(0, 10); + if (opts.minDate && dateOnly(value) < dateOnly(opts.minDate)) { + return settings.date.messages.afterMin(opts.minDate); + } + if (opts.maxDate && dateOnly(value) > dateOnly(opts.maxDate)) { + return settings.date.messages.beforeMax(opts.maxDate); + } if (validate) return validate(value); return undefined; }, @@ -61,7 +72,13 @@ export const date = (opts: DateOptions) => { const title = `${titlePrefix}${opts.message}\n`; const userInput = this.userInputWithCursor; const value = - this.value instanceof Date ? this.value.toISOString().slice(0, 10) : ''; + this.value instanceof Date + ? formatConfig.format({ + year: String(this.value.getFullYear()).padStart(4, '0'), + month: String(this.value.getMonth() + 1).padStart(2, '0'), + day: String(this.value.getDate()).padStart(2, '0'), + }) + : ''; switch (this.state) { case 'error': { @@ -85,10 +102,9 @@ export const date = (opts: DateOptions) => { 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)}` - : ''; + const inlineError = (this as { inlineError?: string }).inlineError + ? `\n${inlineErrorBar}${color.yellow((this as { inlineError: string }).inlineError)}` + : ''; return `${title}${defaultPrefix}${userInput}${inlineError}\n${defaultPrefixEnd}\n`; } } diff --git a/packages/prompts/test/__snapshots__/date.test.ts.snap b/packages/prompts/test/__snapshots__/date.test.ts.snap index 44f06716..d0d8ea8f 100644 --- a/packages/prompts/test/__snapshots__/date.test.ts.snap +++ b/packages/prompts/test/__snapshots__/date.test.ts.snap @@ -5,7 +5,7 @@ exports[`date (isCI = false) > can cancel 1`] = ` "", "│ ◆ Pick a date -│  ___/__/__ +│  yyy/mm/dd └ ", "", @@ -31,7 +31,7 @@ exports[`date (isCI = false) > defaultValue used when empty submit 1`] = ` "", "", "◇ Pick a date -│ 2025-12-25", +│ 2025/12/24", " ", "", @@ -50,7 +50,7 @@ exports[`date (isCI = false) > renders initial value 1`] = ` "", "", "◇ Pick a date -│ 2025-01-15", +│ 2025/01/14", " ", "", @@ -69,7 +69,7 @@ exports[`date (isCI = false) > renders message 1`] = ` "", "", "◇ Pick a date -│ 2025-01-15", +│ 2025/01/14", " ", "", @@ -88,7 +88,7 @@ exports[`date (isCI = false) > renders submitted value 1`] = ` "", "", "◇ Pick a date -│ 2025-06-15", +│ 2025/06/14", " ", "", @@ -107,7 +107,7 @@ exports[`date (isCI = false) > supports MM/DD/YYYY format 1`] = ` "", "", "◇ Pick a date -│ 2025-01-15", +│ 01/14/2025", " ", "", @@ -124,7 +124,7 @@ exports[`date (isCI = false) > withGuide: false removes guide 1`] = ` "", "", "◇ Pick a date - 2025-01-15", + 2025/01/14", " ", "", @@ -136,7 +136,7 @@ exports[`date (isCI = true) > can cancel 1`] = ` "", "│ ◆ Pick a date -│  ___/__/__ +│  yyy/mm/dd └ ", "", @@ -162,7 +162,7 @@ exports[`date (isCI = true) > defaultValue used when empty submit 1`] = ` "", "", "◇ Pick a date -│ 2025-12-25", +│ 2025/12/24", " ", "", @@ -181,7 +181,7 @@ exports[`date (isCI = true) > renders initial value 1`] = ` "", "", "◇ Pick a date -│ 2025-01-15", +│ 2025/01/14", " ", "", @@ -200,7 +200,7 @@ exports[`date (isCI = true) > renders message 1`] = ` "", "", "◇ Pick a date -│ 2025-01-15", +│ 2025/01/14", " ", "", @@ -219,7 +219,7 @@ exports[`date (isCI = true) > renders submitted value 1`] = ` "", "", "◇ Pick a date -│ 2025-06-15", +│ 2025/06/14", " ", "", @@ -238,7 +238,7 @@ exports[`date (isCI = true) > supports MM/DD/YYYY format 1`] = ` "", "", "◇ Pick a date -│ 2025-01-15", +│ 01/14/2025", " ", "", @@ -255,7 +255,7 @@ exports[`date (isCI = true) > withGuide: false removes guide 1`] = ` "", "", "◇ Pick a date - 2025-01-15", + 2025/01/14", " ", "", diff --git a/packages/prompts/test/date.test.ts b/packages/prompts/test/date.test.ts index 53491d56..77243162 100644 --- a/packages/prompts/test/date.test.ts +++ b/packages/prompts/test/date.test.ts @@ -146,4 +146,26 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { expect((value as Date).toISOString().slice(0, 10)).toBe('2025-01-15'); expect(output.buffer).toMatchSnapshot(); }); + + test('minDate shows error when date before min and submit', async () => { + const result = prompts.date({ + message: 'Pick a date', + initialValue: d('2025-01-10'), + minDate: d('2025-01-15'), + input, + output, + }); + + input.emit('keypress', undefined, { name: 'return' }); + await new Promise((r) => setImmediate(r)); + + const hasError = output.buffer.some( + (s) => typeof s === 'string' && s.includes('Date must be on or after') + ); + expect(hasError).toBe(true); + + input.emit('keypress', 'escape', { name: 'escape' }); + const value = await result; + expect(prompts.isCancel(value)).toBe(true); + }); }); From 9f53979a078d185c7eee40d21b1f9ce786aecbce Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Thu, 5 Feb 2026 19:45:23 -0600 Subject: [PATCH 6/8] fix(date): use local date for display so CI snapshots match --- packages/core/src/prompts/date.ts | 32 +++++++++++++------ .../test/__snapshots__/date.test.ts.snap | 24 +++++++------- 2 files changed, 35 insertions(+), 21 deletions(-) diff --git a/packages/core/src/prompts/date.ts b/packages/core/src/prompts/date.ts index ca4541f1..5440f3c9 100644 --- a/packages/core/src/prompts/date.ts +++ b/packages/core/src/prompts/date.ts @@ -130,7 +130,11 @@ function getSegmentBounds( return { min, max }; } -function toISOString(display: string, config: DateFormatConfig): string | undefined { +/** Parse display string to calendar parts; returns undefined if invalid. */ +function parseDisplayToParts( + display: string, + config: DateFormatConfig +): { year: number; month: number; day: number } | undefined { const { year, month, day } = parseDisplayString(display, config); if (!year || year < 1000 || year > 9999) return undefined; if (!month || month < 1 || month > 12) return undefined; @@ -139,8 +143,21 @@ function toISOString(display: string, config: DateFormatConfig): string | undefi if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { return undefined; } - // Use UTC to avoid timezone shifting the date part of toISOString() - return new Date(Date.UTC(year, month - 1, day)).toISOString().slice(0, 10); + return { year, month, day }; +} + +/** Build a Date from display string using local midnight so getFullYear/getMonth/getDate are timezone-stable. */ +function displayToDate(display: string, config: DateFormatConfig): Date | undefined { + const parts = parseDisplayToParts(display, config); + if (!parts) return undefined; + return new Date(parts.year, parts.month - 1, parts.day); +} + +function toISOString(display: string, config: DateFormatConfig): string | undefined { + const parts = parseDisplayToParts(display, config); + if (!parts) return undefined; + // Use UTC so toISOString().slice(0,10) is consistent (YYYY-MM-DD) + return new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).toISOString().slice(0, 10); } function getSegmentValidationMessage( @@ -184,8 +201,7 @@ export default class DatePrompt extends Prompt { #refreshFromSegmentValues() { const display = this.#config.format(this.#segmentValues); this._setUserInput(display); - const iso = toISOString(display, this.#config); - this._setValue(iso ? new Date(iso) : undefined); + this._setValue(displayToDate(display, this.#config) ?? undefined); } get cursor() { @@ -255,8 +271,7 @@ export default class DatePrompt extends Prompt { this._setUserInput(initialDisplay); const firstSeg = this.#config.segments[0]; this._cursor = firstSeg.start; - const iso = toISOString(initialDisplay, this.#config); - this._setValue(iso ? new Date(iso) : undefined); + this._setValue(displayToDate(initialDisplay, config) ?? undefined); this.on('cursor', (key) => this.#onCursor(key)); this.on('key', (char, key) => this.#onKey(char, key)); @@ -435,7 +450,6 @@ export default class DatePrompt extends Prompt { #onFinalize(opts: DateOptions) { const display = this.#config.format(this.#segmentValues); - const iso = toISOString(display, this.#config); - this.value = iso ? new Date(iso) : (opts.defaultValue ?? undefined); + this.value = displayToDate(display, this.#config) ?? opts.defaultValue ?? undefined; } } diff --git a/packages/prompts/test/__snapshots__/date.test.ts.snap b/packages/prompts/test/__snapshots__/date.test.ts.snap index d0d8ea8f..1733127e 100644 --- a/packages/prompts/test/__snapshots__/date.test.ts.snap +++ b/packages/prompts/test/__snapshots__/date.test.ts.snap @@ -31,7 +31,7 @@ exports[`date (isCI = false) > defaultValue used when empty submit 1`] = ` "", "", "◇ Pick a date -│ 2025/12/24", +│ 2025/12/25", " ", "", @@ -50,7 +50,7 @@ exports[`date (isCI = false) > renders initial value 1`] = ` "", "", "◇ Pick a date -│ 2025/01/14", +│ 2025/01/15", " ", "", @@ -69,7 +69,7 @@ exports[`date (isCI = false) > renders message 1`] = ` "", "", "◇ Pick a date -│ 2025/01/14", +│ 2025/01/15", " ", "", @@ -88,7 +88,7 @@ exports[`date (isCI = false) > renders submitted value 1`] = ` "", "", "◇ Pick a date -│ 2025/06/14", +│ 2025/06/15", " ", "", @@ -107,7 +107,7 @@ exports[`date (isCI = false) > supports MM/DD/YYYY format 1`] = ` "", "", "◇ Pick a date -│ 01/14/2025", +│ 01/15/2025", " ", "", @@ -124,7 +124,7 @@ exports[`date (isCI = false) > withGuide: false removes guide 1`] = ` "", "", "◇ Pick a date - 2025/01/14", + 2025/01/15", " ", "", @@ -162,7 +162,7 @@ exports[`date (isCI = true) > defaultValue used when empty submit 1`] = ` "", "", "◇ Pick a date -│ 2025/12/24", +│ 2025/12/25", " ", "", @@ -181,7 +181,7 @@ exports[`date (isCI = true) > renders initial value 1`] = ` "", "", "◇ Pick a date -│ 2025/01/14", +│ 2025/01/15", " ", "", @@ -200,7 +200,7 @@ exports[`date (isCI = true) > renders message 1`] = ` "", "", "◇ Pick a date -│ 2025/01/14", +│ 2025/01/15", " ", "", @@ -219,7 +219,7 @@ exports[`date (isCI = true) > renders submitted value 1`] = ` "", "", "◇ Pick a date -│ 2025/06/14", +│ 2025/06/15", " ", "", @@ -238,7 +238,7 @@ exports[`date (isCI = true) > supports MM/DD/YYYY format 1`] = ` "", "", "◇ Pick a date -│ 01/14/2025", +│ 01/15/2025", " ", "", @@ -255,7 +255,7 @@ exports[`date (isCI = true) > withGuide: false removes guide 1`] = ` "", "", "◇ Pick a date - 2025/01/14", + 2025/01/15", " ", "", From df8a9e140bdd83c1b9bce9e543b8fb5d9733a726 Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Sat, 7 Feb 2026 06:59:33 -0600 Subject: [PATCH 7/8] refactor(date): DRY format rendering and extract helpers --- package.json | 2 +- packages/core/src/prompts/date.ts | 276 +++++++++--------------- packages/core/test/prompts/date.test.ts | 44 ++-- packages/prompts/src/date.ts | 137 ++++++++++-- 4 files changed, 243 insertions(+), 216 deletions(-) diff --git a/package.json b/package.json index b902cead..33ab7ca3 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "typescript": "^5.8.3", "unbuild": "^3.6.0" }, - "packageManager": "pnpm@9.14.2", + "packageManager": "pnpm@10.28.2", "volta": { "node": "20.18.1" } diff --git a/packages/core/src/prompts/date.ts b/packages/core/src/prompts/date.ts index 5440f3c9..28699c57 100644 --- a/packages/core/src/prompts/date.ts +++ b/packages/core/src/prompts/date.ts @@ -1,11 +1,9 @@ import type { Key } from 'node:readline'; -import color from 'picocolors'; import { settings } from '../utils/settings.js'; import Prompt, { type PromptOptions } from './prompt.js'; interface SegmentConfig { type: 'year' | 'month' | 'day'; - start: number; len: number; } @@ -15,27 +13,24 @@ export interface DateParts { day: string; } -const DEFAULT_SEGMENT_LABELS: Record<'year' | 'month' | 'day', string> = { - year: 'yyyy', - month: 'mm', - day: 'dd', -}; - /** Format config passed from prompts package; core does not define presets */ export interface DateFormatConfig { segments: SegmentConfig[]; - displayTemplate: string; format: (parts: DateParts) => string; /** Labels shown when segment is blank (e.g. yyyy, mm, dd). Default: { year: 'yyyy', month: 'mm', day: 'dd' } */ segmentLabels?: { year: string; month: string; day: string }; } -function displayToSegmentValues(display: string, config: DateFormatConfig): DateParts { - const blank: DateParts = { year: '____', month: '__', day: '__' }; - for (const seg of config.segments) { - blank[seg.type] = display.slice(seg.start, seg.start + seg.len); +/** Convert Date directly to segment values - no string parsing needed */ +function dateToSegmentValues(date: Date | undefined): DateParts { + if (!date) { + return { year: '____', month: '__', day: '__' }; } - return blank; + return { + year: String(date.getFullYear()).padStart(4, '0'), + month: String(date.getMonth() + 1).padStart(2, '0'), + day: String(date.getDate()).padStart(2, '0'), + }; } function segmentValuesToParsed(parts: DateParts): { year: number; month: number; day: number } { @@ -47,23 +42,8 @@ function segmentValuesToParsed(parts: DateParts): { year: number; month: number; }; } -function parseDisplayString( - display: string, - config: DateFormatConfig -): { year: number; month: number; day: number } { - return segmentValuesToParsed(displayToSegmentValues(display, config)); -} - -function formatDisplayString( - { year, month, day }: { year: number; month: number; day: number }, - config: DateFormatConfig -): string { - const parts: DateParts = { - year: String(year).padStart(4, '0'), - month: String(month).padStart(2, '0'), - day: String(day).padStart(2, '0'), - }; - return config.format(parts); +function daysInMonth(year: number, month: number): number { + return new Date(year || 2000, month || 1, 0).getDate(); } function clampSegment( @@ -79,8 +59,7 @@ function clampSegment( } // 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)); + return Math.max(1, Math.min(daysInMonth(year, month), value || 1)); } function datePartsUTC(d: Date): { year: number; month: number; day: number } { @@ -118,9 +97,8 @@ function getSegmentBounds( return { min, max }; } // day - const daysInMonth = new Date(year || 2000, month || 1, 0).getDate(); let min = 1; - let max = daysInMonth; + let max = daysInMonth(year, month); if (minParts && year && month && year === minParts.year && month === minParts.month) { min = minParts.day; } @@ -130,12 +108,11 @@ function getSegmentBounds( return { min, max }; } -/** Parse display string to calendar parts; returns undefined if invalid. */ -function parseDisplayToParts( - display: string, - config: DateFormatConfig +/** Parse segment values to calendar parts; returns undefined if invalid. */ +function segmentValuesToParts( + parts: DateParts ): { year: number; month: number; day: number } | undefined { - const { year, month, day } = parseDisplayString(display, config); + const { year, month, day } = segmentValuesToParsed(parts); if (!year || year < 1000 || year > 9999) return undefined; if (!month || month < 1 || month > 12) return undefined; if (!day || day < 1) return undefined; @@ -146,36 +123,31 @@ function parseDisplayToParts( return { year, month, day }; } -/** Build a Date from display string using local midnight so getFullYear/getMonth/getDate are timezone-stable. */ -function displayToDate(display: string, config: DateFormatConfig): Date | undefined { - const parts = parseDisplayToParts(display, config); - if (!parts) return undefined; - return new Date(parts.year, parts.month - 1, parts.day); +/** Build a Date from segment values using local midnight so getFullYear/getMonth/getDate are timezone-stable. */ +function segmentValuesToDate(parts: DateParts): Date | undefined { + const parsed = segmentValuesToParts(parts); + if (!parsed) return undefined; + return new Date(parsed.year, parsed.month - 1, parsed.day); } -function toISOString(display: string, config: DateFormatConfig): string | undefined { - const parts = parseDisplayToParts(display, config); - if (!parts) return undefined; +function segmentValuesToISOString(parts: DateParts): string | undefined { + const parsed = segmentValuesToParts(parts); + if (!parsed) return undefined; // Use UTC so toISOString().slice(0,10) is consistent (YYYY-MM-DD) - return new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).toISOString().slice(0, 10); + return new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day)).toISOString().slice(0, 10); } -function getSegmentValidationMessage( - newDisplay: string, - seg: SegmentConfig, - config: DateFormatConfig -): string | undefined { - const { year, month, day } = parseDisplayString(newDisplay, config); +function getSegmentValidationMessage(parts: DateParts, seg: SegmentConfig): string | undefined { + const { year, month, day } = segmentValuesToParsed(parts); if (seg.type === 'month' && (month < 1 || month > 12)) { return settings.date.messages.invalidMonth; } if (seg.type === 'day') { if (day < 1) return undefined; // incomplete - const daysInMonth = new Date(year || 2000, month || 1, 0).getDate(); - if (day > daysInMonth) { + if (day > daysInMonth(year, month)) { const monthName = month >= 1 && month <= 12 ? settings.date.monthNames[month - 1] : 'this month'; - return settings.date.messages.invalidDay(daysInMonth, monthName); + return settings.date.messages.invalidDay(daysInMonth(year, month), monthName); } } return undefined; @@ -194,6 +166,11 @@ export default class DatePrompt extends Prompt { #segmentValues: DateParts; #minDate: Date | undefined; #maxDate: Date | undefined; + /** Segment-based cursor: { segmentIndex, positionInSegment } */ + #cursor: { segmentIndex: number; positionInSegment: number } = { + segmentIndex: 0, + positionInSegment: 0, + }; /** Inline validation message shown beneath input during editing (e.g. "There are only 12 months") */ inlineError = ''; @@ -201,61 +178,36 @@ export default class DatePrompt extends Prompt { #refreshFromSegmentValues() { const display = this.#config.format(this.#segmentValues); this._setUserInput(display); - this._setValue(displayToDate(display, this.#config) ?? undefined); + this._setValue(segmentValuesToDate(this.#segmentValues) ?? undefined); } get cursor() { - return this._cursor; + // Convert segment cursor to absolute position for backward compatibility + // Calculate start position by summing previous segments' lengths + separators + let start = 0; + for (let i = 0; i < this.#cursor.segmentIndex; i++) { + start += this.#config.segments[i].len + 1; // +1 for separator + } + return start + this.#cursor.positionInSegment; } - get userInputWithCursor() { - const labels = this.#config.segmentLabels ?? DEFAULT_SEGMENT_LABELS; - const sep = color.gray('/'); - if (this.state === 'submit') { - return this.#config.format(this.#segmentValues); - } - const parts: string[] = []; - for (let i = 0; i < this.#config.segments.length; i++) { - if (i > 0) parts.push(sep); - const seg = this.#config.segments[i]; - const val = this.#segmentValues[seg.type]; - const isBlank = !val || val.replace(/_/g, '') === ''; - if (isBlank) { - const label = labels[seg.type]; - const cursorInThis = this._cursor >= seg.start && this._cursor < seg.start + seg.len; - if (cursorInThis) { - const posInSeg = this._cursor - seg.start; - for (let j = 0; j < label.length; j++) { - parts.push(j === posInSeg ? color.inverse(' ') : color.dim(label[j])); - } - } else { - parts.push(color.dim(label)); - } - } else { - for (let j = 0; j < val.length; j++) { - const globalIdx = seg.start + j; - if (globalIdx === this._cursor) { - parts.push(val[j] === '_' ? color.inverse(' ') : color.inverse(val[j])); - } else { - parts.push(val[j] === '_' ? color.dim(' ') : val[j]); - } - } - } - } - if (this._cursor >= this.#config.displayTemplate.length) { - parts.push('█'); - } - return parts.join(''); + /** Get current segment cursor position */ + get segmentCursor() { + return { ...this.#cursor }; + } + + /** Get current segment values for rendering in prompts layer */ + get segmentValues(): DateParts { + return { ...this.#segmentValues }; } constructor(opts: DateOptions) { const config = opts.formatConfig; - const initialDisplay = DatePrompt.#toDisplayString( - opts.initialValue ?? - opts.defaultValue ?? - (opts as { initialUserInput?: string }).initialUserInput, - config - ); + // Convert Date directly to segment values - no string parsing needed + const initialDate = opts.initialValue ?? opts.defaultValue; + const segmentValues = dateToSegmentValues(initialDate); + const initialDisplay = config.format(segmentValues); + super( { ...opts, @@ -264,89 +216,57 @@ export default class DatePrompt extends Prompt { false ); this.#config = config; - this.#segmentValues = displayToSegmentValues(initialDisplay, config); + this.#segmentValues = segmentValues; this.#minDate = opts.minDate; this.#maxDate = opts.maxDate; this._setUserInput(initialDisplay); - const firstSeg = this.#config.segments[0]; - this._cursor = firstSeg.start; - this._setValue(displayToDate(initialDisplay, config) ?? undefined); + // Initialize cursor to first segment, position 0 + this.#cursor = { segmentIndex: 0, positionInSegment: 0 }; + this._setValue(segmentValuesToDate(this.#segmentValues) ?? 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: Date | string | undefined, config: DateFormatConfig): string { - if (!value) return config.displayTemplate; - if (value instanceof Date) { - return formatDisplayString( - { - year: value.getFullYear(), - month: value.getMonth() + 1, - day: value.getDate(), - }, - config - ); - } - if (typeof value === 'string') { - 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, - }; + /** Get current segment and ensure cursor is valid */ + #getCurrentSegment(): { segment: SegmentConfig; index: number } | undefined { + const index = Math.max( + 0, + Math.min(this.#config.segments.length - 1, this.#cursor.segmentIndex) + ); + const seg = this.#config.segments[index]; + if (!seg) return undefined; + // Ensure positionInSegment is within bounds + this.#cursor.positionInSegment = Math.max( + 0, + Math.min(seg.len - 1, this.#cursor.positionInSegment) + ); + return { segment: seg, index }; } #onCursor(key?: string) { if (!key) return; - const ctx = this.#getSegmentAtCursor(); + const ctx = this.#getCurrentSegment(); 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; + const newPosInSeg = this.#cursor.positionInSegment + delta; + // Move within segment first, then between segments - const newPosInSeg = posInSeg + delta; - if (newPosInSeg >= 0 && newPosInSeg < seg.len) { - this._cursor = seg.start + newPosInSeg; + if (newPosInSeg >= 0 && newPosInSeg < ctx.segment.len) { + this.#cursor.positionInSegment = newPosInSeg; return; } + + // Move to next/prev segment const newIndex = Math.max(0, Math.min(this.#config.segments.length - 1, ctx.index + delta)); - const newSeg = this.#config.segments[newIndex]; + this.#cursor.segmentIndex = newIndex; // Always start at beginning of segment for left-to-right digit-by-digit editing - this._cursor = newSeg.start; + this.#cursor.positionInSegment = 0; return; } @@ -384,7 +304,7 @@ export default class DatePrompt extends Prompt { char === '\b'; if (isBackspace) { this.inlineError = ''; - const ctx = this.#getSegmentAtCursor(); + const ctx = this.#getCurrentSegment(); if (!ctx) return; const seg = ctx.segment; const segmentVal = this.#segmentValues[seg.type]; @@ -393,28 +313,26 @@ export default class DatePrompt extends Prompt { // Clear entire segment on backspace at any position this.#segmentValues[seg.type] = '_'.repeat(seg.len); this.#refreshFromSegmentValues(); - this._cursor = seg.start; + this.#cursor.positionInSegment = 0; return; } if (char && /^[0-9]$/.test(char)) { - const ctx = this.#getSegmentAtCursor(); + const ctx = this.#getCurrentSegment(); if (!ctx) return; const seg = ctx.segment; const segmentDisplay = this.#segmentValues[seg.type]; const firstBlank = segmentDisplay.indexOf('_'); - const pos = firstBlank >= 0 ? firstBlank : Math.min(this._cursor - seg.start, seg.len - 1); + const pos = + firstBlank >= 0 ? firstBlank : Math.min(this.#cursor.positionInSegment, seg.len - 1); if (pos < 0 || pos >= seg.len) return; const newSegmentVal = segmentDisplay.slice(0, pos) + char + segmentDisplay.slice(pos + 1); if (!newSegmentVal.includes('_')) { - const newDisplay = this.#config.format({ - ...this.#segmentValues, - [seg.type]: newSegmentVal, - }); - const validationMsg = getSegmentValidationMessage(newDisplay, seg, this.#config); + const newParts = { ...this.#segmentValues, [seg.type]: newSegmentVal }; + const validationMsg = getSegmentValidationMessage(newParts, seg); if (validationMsg) { this.inlineError = validationMsg; return; @@ -423,8 +341,7 @@ export default class DatePrompt extends Prompt { this.inlineError = ''; this.#segmentValues[seg.type] = newSegmentVal; - const newDisplay = this.#config.format(this.#segmentValues); - const iso = toISOString(newDisplay, this.#config); + const iso = segmentValuesToISOString(this.#segmentValues); if (iso) { const { year, month, day } = segmentValuesToParsed(this.#segmentValues); @@ -439,17 +356,18 @@ export default class DatePrompt extends Prompt { const nextBlank = newSegmentVal.indexOf('_'); const wasFilling = firstBlank >= 0; if (nextBlank >= 0) { - this._cursor = seg.start + nextBlank; + this.#cursor.positionInSegment = nextBlank; } else if (wasFilling && ctx.index < this.#config.segments.length - 1) { - this._cursor = this.#config.segments[ctx.index + 1].start; + // Auto-jump to next segment + this.#cursor.segmentIndex = ctx.index + 1; + this.#cursor.positionInSegment = 0; } else { - this._cursor = seg.start + Math.min(pos + 1, seg.len - 1); + this.#cursor.positionInSegment = Math.min(pos + 1, seg.len - 1); } } } #onFinalize(opts: DateOptions) { - const display = this.#config.format(this.#segmentValues); - this.value = displayToDate(display, this.#config) ?? opts.defaultValue ?? undefined; + this.value = segmentValuesToDate(this.#segmentValues) ?? opts.defaultValue ?? undefined; } } diff --git a/packages/core/test/prompts/date.test.ts b/packages/core/test/prompts/date.test.ts index 9cd8ce24..def3c82c 100644 --- a/packages/core/test/prompts/date.test.ts +++ b/packages/core/test/prompts/date.test.ts @@ -1,4 +1,3 @@ -import color from 'picocolors'; import { cursor } from 'sisteransi'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { DateFormatConfig, DateParts } from '../../src/prompts/date.js'; @@ -11,15 +10,11 @@ function buildFormatConfig( format: (p: DateParts) => string, types: ('year' | 'month' | 'day')[] ): DateFormatConfig { - const displayTemplate = format({ year: '____', month: '__', day: '__' }); - const parts = displayTemplate.split('/'); - let start = 0; - const segments = parts.map((part, i) => { - const seg = { type: types[i], start, len: part.length }; - start += part.length + 1; - return seg; + const segments = types.map((type) => { + const len = type === 'year' ? 4 : 2; + return { type, len }; }); - return { segments, displayTemplate, format }; + return { segments, format }; } const YYYY_MM_DD = buildFormatConfig( @@ -349,8 +344,23 @@ describe('DatePrompt', () => { expect(instance.userInput).to.equal('15/01/2025'); }); - describe('userInputWithCursor', () => { - test('highlights character at cursor', () => { + describe('segmentValues and segmentCursor', () => { + test('segmentValues reflects current input', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + formatConfig: YYYY_MM_DD, + initialValue: d('2025-01-15'), + }); + instance.prompt(); + const segmentValues = instance.segmentValues; + expect(segmentValues.year).to.equal('2025'); + expect(segmentValues.month).to.equal('01'); + expect(segmentValues.day).to.equal('15'); + }); + + test('segmentCursor tracks cursor position', () => { const instance = new DatePrompt({ input, output, @@ -360,11 +370,12 @@ describe('DatePrompt', () => { }); 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" + const cursor = instance.segmentCursor; + expect(cursor.segmentIndex).to.equal(1); // month segment + expect(cursor.positionInSegment).to.equal(0); // start of segment }); - test('returns value on submit', () => { + test('segmentValues updates on submit', () => { const instance = new DatePrompt({ input, output, @@ -374,7 +385,10 @@ describe('DatePrompt', () => { }); instance.prompt(); input.emit('keypress', undefined, { name: 'return' }); - expect(instance.userInputWithCursor).to.equal('2025/01/15'); + const segmentValues = instance.segmentValues; + expect(segmentValues.year).to.equal('2025'); + expect(segmentValues.month).to.equal('01'); + expect(segmentValues.day).to.equal('15'); }); }); }); diff --git a/packages/prompts/src/date.ts b/packages/prompts/src/date.ts index 55f85cab..ce3672f3 100644 --- a/packages/prompts/src/date.ts +++ b/packages/prompts/src/date.ts @@ -5,29 +5,107 @@ import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; export type DateFormat = 'YYYY/MM/DD' | 'MM/DD/YYYY' | 'DD/MM/YYYY'; -const formatters: Record string> = { - 'YYYY/MM/DD': (p) => `${p.year}/${p.month}/${p.day}`, - 'MM/DD/YYYY': (p) => `${p.month}/${p.day}/${p.year}`, - 'DD/MM/YYYY': (p) => `${p.day}/${p.month}/${p.year}`, +type CursorState = { segmentIndex: number; positionInSegment: number }; +type RenderState = 'active' | 'submit' | 'cancel' | 'error'; + +const DEFAULT_SEGMENT_LABELS: Record<'year' | 'month' | 'day', string> = { + year: 'yyyy', + month: 'mm', + day: 'dd', }; -function buildFormatConfig(format: DateFormat): DateFormatConfig { - const formatFn = formatters[format]; - const displayTemplate = formatFn({ year: '____', month: '__', day: '__' }); - // Derive segment order from formatter output (e.g. "Y/M/D" → [year, month, day]) - const marker = formatFn({ year: 'Y', month: 'M', day: 'D' }); - const typeMap = { Y: 'year' as const, M: 'month' as const, D: 'day' as const }; - const types = marker.split('/').map((c) => typeMap[c as keyof typeof typeMap]); - const parts = displayTemplate.split('/'); - let start = 0; - const segments = parts.map((part, i) => { - const seg = { type: types[i], start, len: part.length }; - start += part.length + 1; - return seg; - }); - return { segments, displayTemplate, format: formatFn }; +/** Derive a plain formatter from segment order -- single source of truth */ +function makePlainFormatter(segments: DateFormatConfig['segments']): (parts: DateParts) => string { + return (p) => segments.map((s) => p[s.type]).join('/'); +} + +/** Render a single segment with cursor highlighting */ +function renderSegment( + value: string, + segmentIndex: number, + cursor: CursorState, + label: string, + state: RenderState +): string { + const isBlank = !value || value.replace(/_/g, '') === ''; + const cursorInThis = + segmentIndex === cursor.segmentIndex && state !== 'submit' && state !== 'cancel'; + const parts: string[] = []; + + if (isBlank) { + if (cursorInThis) { + for (let j = 0; j < label.length; j++) { + parts.push(j === cursor.positionInSegment ? color.inverse(' ') : color.dim(label[j])); + } + } else { + parts.push(color.dim(label)); + } + } else { + for (let j = 0; j < value.length; j++) { + if (cursorInThis && j === cursor.positionInSegment) { + parts.push(value[j] === '_' ? color.inverse(' ') : color.inverse(value[j])); + } else { + parts.push(value[j] === '_' ? color.dim(' ') : value[j]); + } + } + } + + return parts.join(''); +} + +/** Generic data-driven renderer -- iterates segments from config, no per-format duplication */ +function renderDateFormat( + parts: DateParts, + cursor: CursorState, + state: RenderState, + config: DateFormatConfig +): string { + if (state === 'submit' || state === 'cancel') { + return config.format(parts); + } + const labels = config.segmentLabels ?? DEFAULT_SEGMENT_LABELS; + const sep = color.gray('/'); + const rendered = config.segments.map((seg, i) => + renderSegment(parts[seg.type], i, cursor, labels[seg.type], state) + ); + let result = rendered.join(sep); + // Add cursor block if beyond last segment + const lastSeg = config.segments[config.segments.length - 1]; + if ( + cursor.segmentIndex >= config.segments.length || + (cursor.segmentIndex === config.segments.length - 1 && cursor.positionInSegment >= lastSeg.len) + ) { + result += '█'; + } + return result; } +/** Segment definitions per format -- the single source of truth for order and lengths */ +const SEGMENT_DEFS: Record = { + 'YYYY/MM/DD': [ + { type: 'year', len: 4 }, + { type: 'month', len: 2 }, + { type: 'day', len: 2 }, + ], + 'MM/DD/YYYY': [ + { type: 'month', len: 2 }, + { type: 'day', len: 2 }, + { type: 'year', len: 4 }, + ], + 'DD/MM/YYYY': [ + { type: 'day', len: 2 }, + { type: 'month', len: 2 }, + { type: 'year', len: 4 }, + ], +}; + +/** Pre-computed format configs derived from segment definitions */ +const FORMAT_CONFIGS: Record = Object.fromEntries( + (Object.entries(SEGMENT_DEFS) as [DateFormat, DateFormatConfig['segments']][]).map( + ([key, segments]) => [key, { segments, format: makePlainFormatter(segments) }] + ) +) as Record; + export interface DateOptions extends CommonOptions { message: string; format?: DateFormat; @@ -40,7 +118,7 @@ export interface DateOptions extends CommonOptions { export const date = (opts: DateOptions) => { const validate = opts.validate; - const formatConfig = buildFormatConfig(opts.format ?? 'YYYY/MM/DD'); + const formatConfig = FORMAT_CONFIGS[opts.format ?? 'YYYY/MM/DD']; return new DatePrompt({ formatConfig, defaultValue: opts.defaultValue, @@ -70,7 +148,24 @@ export const date = (opts: DateOptions) => { 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; + + // Get segment values and cursor from core + const segmentValues = this.segmentValues; + const segmentCursor = this.segmentCursor; + + // Determine render state + const renderState: RenderState = + this.state === 'submit' + ? 'submit' + : this.state === 'cancel' + ? 'cancel' + : this.state === 'error' + ? 'error' + : 'active'; + + // Render using generic data-driven renderer + const userInput = renderDateFormat(segmentValues, segmentCursor, renderState, formatConfig); + const value = this.value instanceof Date ? formatConfig.format({ From fc7301fe0f459c03e94e0d9ab6c07a4c25763dcc Mon Sep 17 00:00:00 2001 From: Paul Valladares <85648028+dreyfus92@users.noreply.github.com> Date: Sat, 7 Feb 2026 07:03:59 -0600 Subject: [PATCH 8/8] revert: pnpm's version --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 33ab7ca3..b902cead 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "typescript": "^5.8.3", "unbuild": "^3.6.0" }, - "packageManager": "pnpm@10.28.2", + "packageManager": "pnpm@9.14.2", "volta": { "node": "20.18.1" }