[READ-ONLY] Mirror of https://github.com/bombshell-dev/clack. Effortlessly build beautiful command-line apps bomb.sh/docs/clack/basics/getting-started/
cli command-line command-line-app node prompt prompts
5

Configure Feed

Select the types of activity you want to include in your feed.

feat(core, prompts): add DatePrompt for date input with customizable formats (#448)

authored by

paul valladares and committed by
GitHub
(Mar 11, 2026, 12:03 AM EDT) 090902cf 29a50cb9

+1513 -5
+6
.changeset/tangy-mirrors-hug.md
··· 1 + --- 2 + "@clack/prompts": minor 3 + "@clack/core": minor 4 + --- 5 + 6 + Adds `date` prompt with format support (YYYY/MM/DD, MM/DD/YYYY, DD/MM/YYYY).
+21
examples/basic/date.ts
··· 1 + import * as p from '@clack/prompts'; 2 + import color from 'picocolors'; 3 + 4 + async function main() { 5 + const result = (await p.date({ 6 + message: color.magenta('Pick a date'), 7 + format: 'YYYY/MM/DD', 8 + minDate: new Date('2025-01-01'), 9 + maxDate: new Date('2025-12-31'), 10 + })) as Date; 11 + 12 + if (p.isCancel(result)) { 13 + p.cancel('Operation cancelled.'); 14 + process.exit(0); 15 + } 16 + 17 + const fmt = (d: Date) => d.toISOString().slice(0, 10); 18 + p.outro(`Selected date: ${color.cyan(fmt(result))}`); 19 + } 20 + 21 + main().catch(console.error);
+2 -1
examples/basic/package.json
··· 14 14 "progress": "jiti ./progress.ts", 15 15 "spinner": "jiti ./spinner.ts", 16 16 "path": "jiti ./path.ts", 17 - "spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts", 17 + "date": "jiti ./date.ts", 18 + "spinner-ci": "npx cross-env CI=\"true\" jiti ./spinner-ci.ts", 18 19 "spinner-timer": "jiti ./spinner-timer.ts", 19 20 "task-log": "jiti ./task-log.ts" 20 21 },
+2
packages/core/src/index.ts
··· 2 2 export { default as AutocompletePrompt } from './prompts/autocomplete.js'; 3 3 export type { ConfirmOptions } from './prompts/confirm.js'; 4 4 export { default as ConfirmPrompt } from './prompts/confirm.js'; 5 + export type { DateFormatConfig, DateOptions, DateParts } from './prompts/date.js'; 6 + export { default as DatePrompt } from './prompts/date.js'; 5 7 export type { GroupMultiSelectOptions } from './prompts/group-multiselect.js'; 6 8 export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect.js'; 7 9 export type { MultiSelectOptions } from './prompts/multi-select.js';
+372
packages/core/src/prompts/date.ts
··· 1 + import type { Key } from 'node:readline'; 2 + import { settings } from '../utils/settings.js'; 3 + import Prompt, { type PromptOptions } from './prompt.js'; 4 + 5 + interface SegmentConfig { 6 + type: 'year' | 'month' | 'day'; 7 + len: number; 8 + } 9 + 10 + export interface DateParts { 11 + year: string; 12 + month: string; 13 + day: string; 14 + } 15 + 16 + /** Format config passed from prompts package; core does not define presets */ 17 + export interface DateFormatConfig { 18 + segments: SegmentConfig[]; 19 + format: (parts: DateParts) => string; 20 + /** Labels shown when segment is blank (e.g. yyyy, mm, dd). Default: { year: 'yyyy', month: 'mm', day: 'dd' } */ 21 + segmentLabels?: { year: string; month: string; day: string }; 22 + } 23 + 24 + function clamp(min: number, value: number, max: number): number { 25 + return Math.max(min, Math.min(max, value)); 26 + } 27 + 28 + /** Convert Date directly to segment values - no string parsing needed */ 29 + function dateToSegmentValues(date: Date | undefined): DateParts { 30 + if (!date) { 31 + return { year: '____', month: '__', day: '__' }; 32 + } 33 + return { 34 + year: String(date.getFullYear()).padStart(4, '0'), 35 + month: String(date.getMonth() + 1).padStart(2, '0'), 36 + day: String(date.getDate()).padStart(2, '0'), 37 + }; 38 + } 39 + 40 + function segmentValuesToParsed(parts: DateParts): { year: number; month: number; day: number } { 41 + const val = (s: string) => Number.parseInt((s || '0').replace(/_/g, '0'), 10) || 0; 42 + return { 43 + year: val(parts.year), 44 + month: val(parts.month), 45 + day: val(parts.day), 46 + }; 47 + } 48 + 49 + function daysInMonth(year: number, month: number): number { 50 + return new Date(year || 2001, month || 1, 0).getDate(); 51 + } 52 + 53 + function clampSegment( 54 + value: number, 55 + type: 'year' | 'month' | 'day', 56 + context: { year: number; month: number } 57 + ): number { 58 + if (type === 'year') { 59 + return clamp(1000, value || 1000, 9999); 60 + } 61 + if (type === 'month') { 62 + return clamp(1, value || 1, 12); 63 + } 64 + const { year, month } = context; 65 + return clamp(1, value || 1, daysInMonth(year, month)); 66 + } 67 + 68 + function datePartsUTC(d: Date): { year: number; month: number; day: number } { 69 + return { 70 + year: d.getUTCFullYear(), 71 + month: d.getUTCMonth() + 1, 72 + day: d.getUTCDate(), 73 + }; 74 + } 75 + 76 + function getSegmentBounds( 77 + type: 'year' | 'month' | 'day', 78 + context: { year: number; month: number; day: number }, 79 + minDate: Date | undefined, 80 + maxDate: Date | undefined 81 + ): { min: number; max: number } { 82 + const { year, month } = context; 83 + const minParts = minDate ? datePartsUTC(minDate) : null; 84 + const maxParts = maxDate ? datePartsUTC(maxDate) : null; 85 + 86 + if (type === 'year') { 87 + const min = minParts ? minParts.year : 1000; 88 + const max = maxParts ? maxParts.year : 9999; 89 + return { min, max }; 90 + } 91 + if (type === 'month') { 92 + let min = 1; 93 + let max = 12; 94 + if (minParts && year && year >= minParts.year) { 95 + if (year === minParts.year) min = minParts.month; 96 + } 97 + if (maxParts && year && year <= maxParts.year) { 98 + if (year === maxParts.year) max = maxParts.month; 99 + } 100 + return { min, max }; 101 + } 102 + // day 103 + let min = 1; 104 + let max = daysInMonth(year, month); 105 + if (minParts && year && month && year === minParts.year && month === minParts.month) { 106 + min = minParts.day; 107 + } 108 + if (maxParts && year && month && year === maxParts.year && month === maxParts.month) { 109 + max = maxParts.day; 110 + } 111 + return { min, max }; 112 + } 113 + 114 + /** Parse segment values to calendar parts; returns undefined if invalid. */ 115 + function segmentValuesToParts( 116 + parts: DateParts 117 + ): { year: number; month: number; day: number } | undefined { 118 + const { year, month, day } = segmentValuesToParsed(parts); 119 + if (!year || year < 1000 || year > 9999) return undefined; 120 + if (!month || month < 1 || month > 12) return undefined; 121 + if (!day || day < 1) return undefined; 122 + const date = new Date(year, month - 1, day); 123 + if (date.getFullYear() !== year || date.getMonth() !== month - 1 || date.getDate() !== day) { 124 + return undefined; 125 + } 126 + return { year, month, day }; 127 + } 128 + 129 + /** Build a Date from segment values using local midnight so getFullYear/getMonth/getDate are timezone-stable. */ 130 + function segmentValuesToDate(parts: DateParts): Date | undefined { 131 + const parsed = segmentValuesToParts(parts); 132 + if (!parsed) return undefined; 133 + return new Date(parsed.year, parsed.month - 1, parsed.day); 134 + } 135 + 136 + function segmentValuesToISOString(parts: DateParts): string | undefined { 137 + const parsed = segmentValuesToParts(parts); 138 + if (!parsed) return undefined; 139 + return new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day)).toISOString().slice(0, 10); 140 + } 141 + 142 + function getSegmentValidationMessage(parts: DateParts, seg: SegmentConfig): string | undefined { 143 + const { year, month, day } = segmentValuesToParsed(parts); 144 + if (seg.type === 'month' && (month < 1 || month > 12)) { 145 + return settings.date.messages.invalidMonth; 146 + } 147 + if (seg.type === 'day') { 148 + if (day < 1) return undefined; 149 + if (day > daysInMonth(year, month)) { 150 + const monthName = 151 + month >= 1 && month <= 12 ? settings.date.monthNames[month - 1] : 'this month'; 152 + return settings.date.messages.invalidDay(daysInMonth(year, month), monthName); 153 + } 154 + } 155 + return undefined; 156 + } 157 + 158 + export interface DateOptions extends PromptOptions<Date, DatePrompt> { 159 + formatConfig: DateFormatConfig; 160 + defaultValue?: Date; 161 + initialValue?: Date; 162 + minDate?: Date; 163 + maxDate?: Date; 164 + } 165 + 166 + export default class DatePrompt extends Prompt<Date> { 167 + #config: DateFormatConfig; 168 + #segmentValues: DateParts; 169 + #minDate: Date | undefined; 170 + #maxDate: Date | undefined; 171 + #cursor = { segmentIndex: 0, positionInSegment: 0 }; 172 + 173 + /** Inline validation message shown beneath input during editing */ 174 + inlineError = ''; 175 + 176 + #refreshFromSegmentValues() { 177 + const display = this.#config.format(this.#segmentValues); 178 + this._setUserInput(display); 179 + this._setValue(segmentValuesToDate(this.#segmentValues) ?? undefined); 180 + } 181 + 182 + get segmentCursor() { 183 + return { ...this.#cursor }; 184 + } 185 + 186 + get segmentValues(): DateParts { 187 + return { ...this.#segmentValues }; 188 + } 189 + 190 + constructor(opts: DateOptions) { 191 + const config = opts.formatConfig; 192 + const initialDate = opts.initialValue ?? opts.defaultValue; 193 + const segmentValues = dateToSegmentValues(initialDate); 194 + const initialDisplay = config.format(segmentValues); 195 + 196 + super( 197 + { 198 + ...opts, 199 + initialUserInput: initialDisplay, 200 + }, 201 + false 202 + ); 203 + this.#config = config; 204 + this.#segmentValues = segmentValues; 205 + this.#minDate = opts.minDate; 206 + this.#maxDate = opts.maxDate; 207 + this.#refreshFromSegmentValues(); 208 + 209 + this.on('cursor', (key) => this.#onCursor(key)); 210 + this.on('key', (char, key) => this.#onKey(char, key)); 211 + this.on('finalize', () => this.#onFinalize(opts)); 212 + } 213 + 214 + #getCurrentSegment(): { segment: SegmentConfig; index: number } | undefined { 215 + const index = clamp(0, this.#cursor.segmentIndex, this.#config.segments.length - 1); 216 + const segment = this.#config.segments[index]; 217 + if (!segment) return undefined; 218 + this.#cursor.positionInSegment = clamp(0, this.#cursor.positionInSegment, segment.len - 1); 219 + return { segment, index }; 220 + } 221 + 222 + #moveCursorNext() { 223 + this.inlineError = ''; 224 + const ctx = this.#getCurrentSegment(); 225 + if (!ctx) return; 226 + const newPos = this.#cursor.positionInSegment + 1; 227 + if (newPos < ctx.segment.len) { 228 + this.#cursor.positionInSegment = newPos; 229 + return; 230 + } 231 + const newIndex = Math.min(this.#config.segments.length - 1, ctx.index + 1); 232 + this.#cursor.segmentIndex = newIndex; 233 + this.#cursor.positionInSegment = 0; 234 + } 235 + 236 + #moveCursorPrevious() { 237 + this.inlineError = ''; 238 + const ctx = this.#getCurrentSegment(); 239 + if (!ctx) return; 240 + const newPos = this.#cursor.positionInSegment - 1; 241 + if (newPos >= 0) { 242 + this.#cursor.positionInSegment = newPos; 243 + return; 244 + } 245 + const newIndex = Math.max(0, ctx.index - 1); 246 + this.#cursor.segmentIndex = newIndex; 247 + this.#cursor.positionInSegment = 0; 248 + } 249 + 250 + #incrementSegment() { 251 + const ctx = this.#getCurrentSegment(); 252 + if (!ctx) return; 253 + this.#adjustSegment(ctx, 1); 254 + } 255 + 256 + #decrementSegment() { 257 + const ctx = this.#getCurrentSegment(); 258 + if (!ctx) return; 259 + this.#adjustSegment(ctx, -1); 260 + } 261 + 262 + #adjustSegment(ctx: { segment: SegmentConfig; index: number }, direction: 1 | -1) { 263 + const { segment } = ctx; 264 + const raw = this.#segmentValues[segment.type]; 265 + const isBlank = !raw || raw.replace(/_/g, '') === ''; 266 + const num = Number.parseInt((raw || '0').replace(/_/g, '0'), 10) || 0; 267 + const bounds = getSegmentBounds( 268 + segment.type, 269 + segmentValuesToParsed(this.#segmentValues), 270 + this.#minDate, 271 + this.#maxDate 272 + ); 273 + 274 + const newNum = isBlank 275 + ? direction === 1 276 + ? bounds.min 277 + : bounds.max 278 + : clamp(bounds.min, num + direction, bounds.max); 279 + 280 + const newSegmentValue = String(newNum).padStart(segment.len, '0'); 281 + this.#segmentValues = { ...this.#segmentValues, [segment.type]: newSegmentValue }; 282 + this.#refreshFromSegmentValues(); 283 + } 284 + 285 + #onCursor(key?: string) { 286 + if (!key) return; 287 + switch (key) { 288 + case 'right': 289 + return this.#moveCursorNext(); 290 + case 'left': 291 + return this.#moveCursorPrevious(); 292 + case 'up': 293 + return this.#incrementSegment(); 294 + case 'down': 295 + return this.#decrementSegment(); 296 + } 297 + } 298 + 299 + #onKey(char: string | undefined, key: Key) { 300 + const isBackspace = 301 + key?.name === 'backspace' || 302 + key?.sequence === '\x7f' || 303 + key?.sequence === '\b' || 304 + char === '\x7f' || 305 + char === '\b'; 306 + if (isBackspace) { 307 + this.inlineError = ''; 308 + const ctx = this.#getCurrentSegment(); 309 + if (!ctx) return; 310 + const { segment } = ctx; 311 + const segmentVal = this.#segmentValues[segment.type]; 312 + if (!segmentVal.replace(/_/g, '')) return; 313 + 314 + this.#segmentValues[segment.type] = '_'.repeat(segment.len); 315 + this.#refreshFromSegmentValues(); 316 + this.#cursor.positionInSegment = 0; 317 + return; 318 + } 319 + 320 + if (char && /^[0-9]$/.test(char)) { 321 + const ctx = this.#getCurrentSegment(); 322 + if (!ctx) return; 323 + const { segment } = ctx; 324 + const segmentDisplay = this.#segmentValues[segment.type]; 325 + 326 + const firstBlank = segmentDisplay.indexOf('_'); 327 + const pos = 328 + firstBlank >= 0 ? firstBlank : Math.min(this.#cursor.positionInSegment, segment.len - 1); 329 + if (pos < 0 || pos >= segment.len) return; 330 + 331 + const newSegmentVal = segmentDisplay.slice(0, pos) + char + segmentDisplay.slice(pos + 1); 332 + 333 + if (!newSegmentVal.includes('_')) { 334 + const newParts = { ...this.#segmentValues, [segment.type]: newSegmentVal }; 335 + const validationMsg = getSegmentValidationMessage(newParts, segment); 336 + if (validationMsg) { 337 + this.inlineError = validationMsg; 338 + return; 339 + } 340 + } 341 + this.inlineError = ''; 342 + 343 + this.#segmentValues[segment.type] = newSegmentVal; 344 + const iso = segmentValuesToISOString(this.#segmentValues); 345 + 346 + if (iso) { 347 + const { year, month, day } = segmentValuesToParsed(this.#segmentValues); 348 + this.#segmentValues = { 349 + year: String(clampSegment(year, 'year', { year, month })).padStart(4, '0'), 350 + month: String(clampSegment(month, 'month', { year, month })).padStart(2, '0'), 351 + day: String(clampSegment(day, 'day', { year, month })).padStart(2, '0'), 352 + }; 353 + } 354 + this.#refreshFromSegmentValues(); 355 + 356 + const nextBlank = newSegmentVal.indexOf('_'); 357 + const wasFilling = firstBlank >= 0; 358 + if (nextBlank >= 0) { 359 + this.#cursor.positionInSegment = nextBlank; 360 + } else if (wasFilling && ctx.index < this.#config.segments.length - 1) { 361 + this.#cursor.segmentIndex = ctx.index + 1; 362 + this.#cursor.positionInSegment = 0; 363 + } else { 364 + this.#cursor.positionInSegment = Math.min(pos + 1, segment.len - 1); 365 + } 366 + } 367 + } 368 + 369 + #onFinalize(opts: DateOptions) { 370 + this.value = segmentValuesToDate(this.#segmentValues) ?? opts.defaultValue ?? undefined; 371 + } 372 + }
+72
packages/core/src/utils/settings.ts
··· 1 1 const actions = ['up', 'down', 'left', 'right', 'space', 'enter', 'cancel'] as const; 2 2 export type Action = (typeof actions)[number]; 3 3 4 + const DEFAULT_MONTH_NAMES = [ 5 + 'January', 6 + 'February', 7 + 'March', 8 + 'April', 9 + 'May', 10 + 'June', 11 + 'July', 12 + 'August', 13 + 'September', 14 + 'October', 15 + 'November', 16 + 'December', 17 + ]; 18 + 4 19 /** Global settings for Clack programs, stored in memory */ 5 20 interface InternalClackSettings { 6 21 actions: Set<Action>; ··· 10 25 error: string; 11 26 }; 12 27 withGuide: boolean; 28 + date: { 29 + monthNames: string[]; 30 + messages: { 31 + invalidMonth: string; 32 + invalidDay: (days: number, month: string) => string; 33 + afterMin: (min: Date) => string; 34 + beforeMax: (max: Date) => string; 35 + }; 36 + }; 13 37 } 14 38 15 39 export const settings: InternalClackSettings = { ··· 29 53 error: 'Something went wrong', 30 54 }, 31 55 withGuide: true, 56 + date: { 57 + monthNames: [...DEFAULT_MONTH_NAMES], 58 + messages: { 59 + invalidMonth: 'There are only 12 months in a year', 60 + invalidDay: (days, month) => `There are only ${days} days in ${month}`, 61 + afterMin: (min) => `Date must be on or after ${min.toISOString().slice(0, 10)}`, 62 + beforeMax: (max) => `Date must be on or before ${max.toISOString().slice(0, 10)}`, 63 + }, 64 + }, 32 65 }; 33 66 34 67 export interface ClackSettings { ··· 58 91 }; 59 92 60 93 withGuide?: boolean; 94 + 95 + /** 96 + * Date prompt localization 97 + */ 98 + date?: { 99 + /** Month names for validation messages (January, February, ...) */ 100 + monthNames?: string[]; 101 + messages?: { 102 + /** Shown when month > 12 */ 103 + invalidMonth?: string; 104 + /** (days, monthName) => message for invalid day */ 105 + invalidDay?: (days: number, month: string) => string; 106 + /** (min) => message when date is before minDate */ 107 + afterMin?: (min: Date) => string; 108 + /** (max) => message when date is after maxDate */ 109 + beforeMax?: (max: Date) => string; 110 + }; 111 + }; 61 112 } 62 113 63 114 export function updateSettings(updates: ClackSettings) { ··· 88 139 89 140 if (updates.withGuide !== undefined) { 90 141 settings.withGuide = updates.withGuide !== false; 142 + } 143 + 144 + if (updates.date !== undefined) { 145 + const date = updates.date; 146 + if (date.monthNames !== undefined) { 147 + settings.date.monthNames = [...date.monthNames]; 148 + } 149 + if (date.messages !== undefined) { 150 + if (date.messages.invalidMonth !== undefined) { 151 + settings.date.messages.invalidMonth = date.messages.invalidMonth; 152 + } 153 + if (date.messages.invalidDay !== undefined) { 154 + settings.date.messages.invalidDay = date.messages.invalidDay; 155 + } 156 + if (date.messages.afterMin !== undefined) { 157 + settings.date.messages.afterMin = date.messages.afterMin; 158 + } 159 + if (date.messages.beforeMax !== undefined) { 160 + settings.date.messages.beforeMax = date.messages.beforeMax; 161 + } 162 + } 91 163 } 92 164 } 93 165
+394
packages/core/test/prompts/date.test.ts
··· 1 + import { cursor } from 'sisteransi'; 2 + import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 3 + import type { DateFormatConfig, DateParts } from '../../src/prompts/date.js'; 4 + import { default as DatePrompt } from '../../src/prompts/date.js'; 5 + import { isCancel } from '../../src/utils/index.js'; 6 + import { MockReadable } from '../mock-readable.js'; 7 + import { MockWritable } from '../mock-writable.js'; 8 + 9 + function buildFormatConfig( 10 + format: (p: DateParts) => string, 11 + types: ('year' | 'month' | 'day')[] 12 + ): DateFormatConfig { 13 + const segments = types.map((type) => { 14 + const len = type === 'year' ? 4 : 2; 15 + return { type, len }; 16 + }); 17 + return { segments, format }; 18 + } 19 + 20 + const YYYY_MM_DD = buildFormatConfig( 21 + (p) => `${p.year}/${p.month}/${p.day}`, 22 + ['year', 'month', 'day'] 23 + ); 24 + const MM_DD_YYYY = buildFormatConfig( 25 + (p) => `${p.month}/${p.day}/${p.year}`, 26 + ['month', 'day', 'year'] 27 + ); 28 + const DD_MM_YYYY = buildFormatConfig( 29 + (p) => `${p.day}/${p.month}/${p.year}`, 30 + ['day', 'month', 'year'] 31 + ); 32 + 33 + const d = (iso: string) => { 34 + const [y, m, day] = iso.slice(0, 10).split('-').map(Number); 35 + return new Date(y, m - 1, day); 36 + }; 37 + 38 + describe('DatePrompt', () => { 39 + let input: MockReadable; 40 + let output: MockWritable; 41 + 42 + beforeEach(() => { 43 + input = new MockReadable(); 44 + output = new MockWritable(); 45 + }); 46 + 47 + afterEach(() => { 48 + vi.restoreAllMocks(); 49 + }); 50 + 51 + test('renders render() result', () => { 52 + const instance = new DatePrompt({ 53 + input, 54 + output, 55 + render: () => 'foo', 56 + formatConfig: YYYY_MM_DD, 57 + }); 58 + instance.prompt(); 59 + expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); 60 + }); 61 + 62 + test('initial value displays correctly', () => { 63 + const instance = new DatePrompt({ 64 + input, 65 + output, 66 + render: () => 'foo', 67 + formatConfig: YYYY_MM_DD, 68 + initialValue: d('2025-01-15'), 69 + }); 70 + instance.prompt(); 71 + expect(instance.userInput).to.equal('2025/01/15'); 72 + expect(instance.value).toBeInstanceOf(Date); 73 + expect(instance.value!.toISOString().slice(0, 10)).to.equal('2025-01-15'); 74 + }); 75 + 76 + test('left/right navigates between segments', () => { 77 + const instance = new DatePrompt({ 78 + input, 79 + output, 80 + render: () => 'foo', 81 + formatConfig: YYYY_MM_DD, 82 + initialValue: d('2025-01-15'), 83 + }); 84 + instance.prompt(); 85 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); 86 + // Move within year (0->1->2->3), then right from end goes to month 87 + for (let i = 0; i < 4; i++) { 88 + input.emit('keypress', undefined, { name: 'right' }); 89 + } 90 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); 91 + for (let i = 0; i < 2; i++) { 92 + input.emit('keypress', undefined, { name: 'right' }); 93 + } 94 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 2, positionInSegment: 0 }); 95 + input.emit('keypress', undefined, { name: 'left' }); 96 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); 97 + }); 98 + 99 + test('up/down increments and decrements segment', () => { 100 + const instance = new DatePrompt({ 101 + input, 102 + output, 103 + render: () => 'foo', 104 + formatConfig: YYYY_MM_DD, 105 + initialValue: d('2025-01-15'), 106 + }); 107 + instance.prompt(); 108 + for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month 109 + input.emit('keypress', undefined, { name: 'up' }); 110 + expect(instance.userInput).to.equal('2025/02/15'); 111 + input.emit('keypress', undefined, { name: 'down' }); 112 + expect(instance.userInput).to.equal('2025/01/15'); 113 + }); 114 + 115 + test('up/down on one segment leaves other segments blank', () => { 116 + const instance = new DatePrompt({ 117 + input, 118 + output, 119 + render: () => 'foo', 120 + formatConfig: YYYY_MM_DD, 121 + }); 122 + instance.prompt(); 123 + expect(instance.userInput).to.equal('____/__/__'); 124 + input.emit('keypress', undefined, { name: 'up' }); // up on year (first segment) 125 + expect(instance.userInput).to.equal('1000/__/__'); 126 + input.emit('keypress', undefined, { name: 'right' }); 127 + input.emit('keypress', undefined, { name: 'right' }); 128 + input.emit('keypress', undefined, { name: 'right' }); 129 + input.emit('keypress', undefined, { name: 'right' }); // move to month 130 + input.emit('keypress', undefined, { name: 'up' }); 131 + expect(instance.userInput).to.equal('1000/01/__'); 132 + }); 133 + 134 + test('with minDate/maxDate, up on blank segment starts at min', () => { 135 + const instance = new DatePrompt({ 136 + input, 137 + output, 138 + render: () => 'foo', 139 + formatConfig: YYYY_MM_DD, 140 + minDate: d('2025-03-10'), 141 + maxDate: d('2025-11-20'), 142 + }); 143 + instance.prompt(); 144 + expect(instance.userInput).to.equal('____/__/__'); 145 + input.emit('keypress', undefined, { name: 'up' }); 146 + expect(instance.userInput).to.equal('2025/__/__'); 147 + for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); 148 + input.emit('keypress', undefined, { name: 'up' }); 149 + expect(instance.userInput).to.equal('2025/03/__'); 150 + for (let i = 0; i < 2; i++) input.emit('keypress', undefined, { name: 'right' }); 151 + input.emit('keypress', undefined, { name: 'up' }); 152 + expect(instance.userInput).to.equal('2025/03/10'); 153 + }); 154 + 155 + test('with minDate/maxDate, down on blank segment starts at max', () => { 156 + const instance = new DatePrompt({ 157 + input, 158 + output, 159 + render: () => 'foo', 160 + formatConfig: YYYY_MM_DD, 161 + minDate: d('2025-03-10'), 162 + maxDate: d('2025-11-20'), 163 + }); 164 + instance.prompt(); 165 + input.emit('keypress', undefined, { name: 'down' }); 166 + expect(instance.userInput).to.equal('2025/__/__'); 167 + for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); 168 + input.emit('keypress', undefined, { name: 'down' }); 169 + expect(instance.userInput).to.equal('2025/11/__'); 170 + for (let i = 0; i < 2; i++) input.emit('keypress', undefined, { name: 'right' }); 171 + input.emit('keypress', undefined, { name: 'down' }); 172 + expect(instance.userInput).to.equal('2025/11/20'); 173 + }); 174 + 175 + test('digit-by-digit editing from left to right', () => { 176 + const instance = new DatePrompt({ 177 + input, 178 + output, 179 + render: () => 'foo', 180 + formatConfig: YYYY_MM_DD, 181 + initialValue: d('2025-01-15'), 182 + }); 183 + instance.prompt(); 184 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); 185 + // Type 2,0,2,3 to change 2025 -> 2023 (edit digit by digit) 186 + input.emit('keypress', '2', { name: undefined, sequence: '2' }); 187 + input.emit('keypress', '0', { name: undefined, sequence: '0' }); 188 + input.emit('keypress', '2', { name: undefined, sequence: '2' }); 189 + input.emit('keypress', '3', { name: undefined, sequence: '3' }); 190 + expect(instance.userInput).to.equal('2023/01/15'); 191 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 3 }); 192 + }); 193 + 194 + test('backspace clears entire segment at any cursor position', () => { 195 + const instance = new DatePrompt({ 196 + input, 197 + output, 198 + render: () => 'foo', 199 + formatConfig: YYYY_MM_DD, 200 + initialValue: d('2025-12-21'), 201 + }); 202 + instance.prompt(); 203 + expect(instance.userInput).to.equal('2025/12/21'); 204 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); 205 + // Backspace at first position clears whole year segment 206 + input.emit('keypress', undefined, { name: 'backspace', sequence: '\x7f' }); 207 + expect(instance.userInput).to.equal('____/12/21'); 208 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); 209 + }); 210 + 211 + test('backspace clears segment when cursor at first char (2___)', () => { 212 + const instance = new DatePrompt({ 213 + input, 214 + output, 215 + render: () => 'foo', 216 + formatConfig: YYYY_MM_DD, 217 + }); 218 + instance.prompt(); 219 + // Type "2" to get "2___" 220 + input.emit('keypress', '2', { name: undefined, sequence: '2' }); 221 + expect(instance.userInput).to.equal('2___/__/__'); 222 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 1 }); 223 + // Move to first char (position 0) 224 + input.emit('keypress', undefined, { name: 'left' }); 225 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); 226 + // Backspace should clear whole segment - also test char-based detection 227 + input.emit('keypress', '\x7f', { name: undefined, sequence: '\x7f' }); 228 + expect(instance.userInput).to.equal('____/__/__'); 229 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); 230 + }); 231 + 232 + test('digit input updates segment and jumps to next when complete', () => { 233 + const instance = new DatePrompt({ 234 + input, 235 + output, 236 + render: () => 'foo', 237 + formatConfig: YYYY_MM_DD, 238 + }); 239 + instance.prompt(); 240 + // Type year 2025 - left-to-right, jumps to month when year complete 241 + for (const c of '2025') { 242 + input.emit('keypress', c, { name: undefined, sequence: c }); 243 + } 244 + expect(instance.userInput).to.equal('2025/__/__'); 245 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); 246 + }); 247 + 248 + test('submit returns ISO string for valid date', async () => { 249 + const instance = new DatePrompt({ 250 + input, 251 + output, 252 + render: () => 'foo', 253 + formatConfig: YYYY_MM_DD, 254 + initialValue: d('2025-01-31'), 255 + }); 256 + const resultPromise = instance.prompt(); 257 + input.emit('keypress', undefined, { name: 'return' }); 258 + const result = await resultPromise; 259 + expect(result).toBeInstanceOf(Date); 260 + expect((result as Date).toISOString().slice(0, 10)).to.equal('2025-01-31'); 261 + }); 262 + 263 + test('can cancel', async () => { 264 + const instance = new DatePrompt({ 265 + input, 266 + output, 267 + render: () => 'foo', 268 + formatConfig: YYYY_MM_DD, 269 + initialValue: d('2025-01-15'), 270 + }); 271 + const resultPromise = instance.prompt(); 272 + input.emit('keypress', 'escape', { name: 'escape' }); 273 + const result = await resultPromise; 274 + expect(isCancel(result)).toBe(true); 275 + }); 276 + 277 + test('defaultValue used when invalid date submitted', async () => { 278 + const instance = new DatePrompt({ 279 + input, 280 + output, 281 + render: () => 'foo', 282 + formatConfig: YYYY_MM_DD, 283 + defaultValue: d('2025-06-15'), 284 + }); 285 + const resultPromise = instance.prompt(); 286 + input.emit('keypress', undefined, { name: 'return' }); 287 + const result = await resultPromise; 288 + expect(result).toBeInstanceOf(Date); 289 + expect((result as Date).toISOString().slice(0, 10)).to.equal('2025-06-15'); 290 + }); 291 + 292 + test('supports MM/DD/YYYY format', () => { 293 + const instance = new DatePrompt({ 294 + input, 295 + output, 296 + render: () => 'foo', 297 + formatConfig: MM_DD_YYYY, 298 + initialValue: d('2025-01-15'), 299 + }); 300 + instance.prompt(); 301 + expect(instance.userInput).to.equal('01/15/2025'); 302 + }); 303 + 304 + test('rejects invalid month and shows inline error', () => { 305 + const instance = new DatePrompt({ 306 + input, 307 + output, 308 + render: () => 'foo', 309 + formatConfig: YYYY_MM_DD, 310 + initialValue: d('2025-01-15'), // month is 01 311 + }); 312 + instance.prompt(); 313 + for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month (cursor at start) 314 + input.emit('keypress', '3', { name: undefined, sequence: '3' }); // 0→3 gives 31, invalid 315 + expect(instance.userInput).to.equal('2025/01/15'); // stayed - 31 rejected 316 + expect(instance.inlineError).to.equal('There are only 12 months in a year'); 317 + }); 318 + 319 + test('rejects invalid day and shows inline error', () => { 320 + const instance = new DatePrompt({ 321 + input, 322 + output, 323 + render: () => 'foo', 324 + formatConfig: YYYY_MM_DD, 325 + initialValue: d('2025-01-15'), // January has 31 days 326 + }); 327 + instance.prompt(); 328 + for (let i = 0; i < 6; i++) input.emit('keypress', undefined, { name: 'right' }); // move to day (cursor at start) 329 + input.emit('keypress', '4', { name: undefined, sequence: '4' }); // 1→4 gives 45, invalid for Jan 330 + expect(instance.userInput).to.equal('2025/01/15'); // stayed - 45 rejected 331 + expect(instance.inlineError).to.contain('31 days'); 332 + expect(instance.inlineError).to.contain('January'); 333 + }); 334 + 335 + test('supports DD/MM/YYYY format', () => { 336 + const instance = new DatePrompt({ 337 + input, 338 + output, 339 + render: () => 'foo', 340 + formatConfig: DD_MM_YYYY, 341 + initialValue: d('2025-01-15'), 342 + }); 343 + instance.prompt(); 344 + expect(instance.userInput).to.equal('15/01/2025'); 345 + }); 346 + 347 + describe('segmentValues and segmentCursor', () => { 348 + test('segmentValues reflects current input', () => { 349 + const instance = new DatePrompt({ 350 + input, 351 + output, 352 + render: () => 'foo', 353 + formatConfig: YYYY_MM_DD, 354 + initialValue: d('2025-01-15'), 355 + }); 356 + instance.prompt(); 357 + const segmentValues = instance.segmentValues; 358 + expect(segmentValues.year).to.equal('2025'); 359 + expect(segmentValues.month).to.equal('01'); 360 + expect(segmentValues.day).to.equal('15'); 361 + }); 362 + 363 + test('segmentCursor tracks cursor position', () => { 364 + const instance = new DatePrompt({ 365 + input, 366 + output, 367 + render: () => 'foo', 368 + formatConfig: YYYY_MM_DD, 369 + initialValue: d('2025-01-15'), 370 + }); 371 + instance.prompt(); 372 + for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month 373 + const cursor = instance.segmentCursor; 374 + expect(cursor.segmentIndex).to.equal(1); // month segment 375 + expect(cursor.positionInSegment).to.equal(0); // start of segment 376 + }); 377 + 378 + test('segmentValues updates on submit', () => { 379 + const instance = new DatePrompt({ 380 + input, 381 + output, 382 + render: () => 'foo', 383 + formatConfig: YYYY_MM_DD, 384 + initialValue: d('2025-01-15'), 385 + }); 386 + instance.prompt(); 387 + input.emit('keypress', undefined, { name: 'return' }); 388 + const segmentValues = instance.segmentValues; 389 + expect(segmentValues.year).to.equal('2025'); 390 + expect(segmentValues.month).to.equal('01'); 391 + expect(segmentValues.day).to.equal('15'); 392 + }); 393 + }); 394 + });
+2 -2
packages/core/test/prompts/password.test.ts
··· 78 78 input.emit('keypress', 'x', { name: 'x' }); 79 79 input.emit('keypress', 'y', { name: 'y' }); 80 80 input.emit('keypress', 'z', { name: 'z' }); 81 - input.emit('keypress', 'left', { name: 'left' }); 82 - input.emit('keypress', 'left', { name: 'left' }); 81 + input.emit('keypress', undefined, { name: 'left' }); 82 + input.emit('keypress', undefined, { name: 'left' }); 83 83 expect(instance.userInputWithCursor).to.equal(`•${styleText('inverse', '•')}•`); 84 84 }); 85 85
+2 -2
packages/core/test/prompts/text.test.ts
··· 92 92 for (let i = 0; i < keys.length; i++) { 93 93 input.emit('keypress', keys[i], { name: keys[i] }); 94 94 } 95 - input.emit('keypress', 'left', { name: 'left' }); 95 + input.emit('keypress', undefined, { name: 'left' }); 96 96 expect(instance.userInputWithCursor).to.equal(`fo${styleText('inverse', 'o')}`); 97 97 }); 98 98 ··· 107 107 for (let i = 0; i < keys.length; i++) { 108 108 input.emit('keypress', keys[i], { name: keys[i] }); 109 109 } 110 - input.emit('keypress', 'right', { name: 'right' }); 110 + input.emit('keypress', undefined, { name: 'right' }); 111 111 expect(instance.userInputWithCursor).to.equal('foo█'); 112 112 }); 113 113
+205
packages/prompts/src/date.ts
··· 1 + import { styleText } from 'node:util'; 2 + import type { DateFormatConfig, DateParts } from '@clack/core'; 3 + import { DatePrompt, settings } from '@clack/core'; 4 + import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; 5 + 6 + export type DateFormat = 'YYYY/MM/DD' | 'MM/DD/YYYY' | 'DD/MM/YYYY'; 7 + 8 + type CursorState = { segmentIndex: number; positionInSegment: number }; 9 + type RenderState = 'active' | 'submit' | 'cancel' | 'error'; 10 + 11 + const DEFAULT_SEGMENT_LABELS: Record<'year' | 'month' | 'day', string> = { 12 + year: 'yyyy', 13 + month: 'mm', 14 + day: 'dd', 15 + }; 16 + 17 + /** Derive a plain formatter from segment order -- single source of truth */ 18 + function makePlainFormatter(segments: DateFormatConfig['segments']): (parts: DateParts) => string { 19 + return (p) => segments.map((s) => p[s.type]).join('/'); 20 + } 21 + 22 + /** Render a single segment with cursor highlighting */ 23 + function renderSegment( 24 + value: string, 25 + segmentIndex: number, 26 + cursor: CursorState, 27 + label: string, 28 + state: RenderState 29 + ): string { 30 + const isBlank = !value || value.replace(/_/g, '') === ''; 31 + const cursorInThis = 32 + segmentIndex === cursor.segmentIndex && state !== 'submit' && state !== 'cancel'; 33 + const parts: string[] = []; 34 + 35 + if (isBlank) { 36 + if (cursorInThis) { 37 + for (let j = 0; j < label.length; j++) { 38 + parts.push( 39 + j === cursor.positionInSegment ? styleText('inverse', ' ') : styleText('dim', label[j]) 40 + ); 41 + } 42 + } else { 43 + parts.push(styleText('dim', label)); 44 + } 45 + } else { 46 + for (let j = 0; j < value.length; j++) { 47 + if (cursorInThis && j === cursor.positionInSegment) { 48 + parts.push(value[j] === '_' ? styleText('inverse', ' ') : styleText('inverse', value[j])); 49 + } else { 50 + parts.push(value[j] === '_' ? styleText('dim', ' ') : value[j]); 51 + } 52 + } 53 + } 54 + 55 + return parts.join(''); 56 + } 57 + 58 + /** Generic data-driven renderer -- iterates segments from config, no per-format duplication */ 59 + function renderDateFormat( 60 + parts: DateParts, 61 + cursor: CursorState, 62 + state: RenderState, 63 + config: DateFormatConfig 64 + ): string { 65 + if (state === 'submit' || state === 'cancel') { 66 + return config.format(parts); 67 + } 68 + const labels = config.segmentLabels ?? DEFAULT_SEGMENT_LABELS; 69 + const sep = styleText('gray', '/'); 70 + const rendered = config.segments.map((seg, i) => 71 + renderSegment(parts[seg.type], i, cursor, labels[seg.type], state) 72 + ); 73 + let result = rendered.join(sep); 74 + const lastSeg = config.segments[config.segments.length - 1]; 75 + if ( 76 + cursor.segmentIndex >= config.segments.length || 77 + (cursor.segmentIndex === config.segments.length - 1 && cursor.positionInSegment >= lastSeg.len) 78 + ) { 79 + result += '█'; 80 + } 81 + return result; 82 + } 83 + 84 + /** Segment definitions per format -- the single source of truth for order and lengths */ 85 + const SEGMENT_DEFS: Record<DateFormat, DateFormatConfig['segments']> = { 86 + 'YYYY/MM/DD': [ 87 + { type: 'year', len: 4 }, 88 + { type: 'month', len: 2 }, 89 + { type: 'day', len: 2 }, 90 + ], 91 + 'MM/DD/YYYY': [ 92 + { type: 'month', len: 2 }, 93 + { type: 'day', len: 2 }, 94 + { type: 'year', len: 4 }, 95 + ], 96 + 'DD/MM/YYYY': [ 97 + { type: 'day', len: 2 }, 98 + { type: 'month', len: 2 }, 99 + { type: 'year', len: 4 }, 100 + ], 101 + }; 102 + 103 + /** Pre-computed format configs derived from segment definitions */ 104 + const FORMAT_CONFIGS: Record<DateFormat, DateFormatConfig> = Object.fromEntries( 105 + (Object.entries(SEGMENT_DEFS) as [DateFormat, DateFormatConfig['segments']][]).map( 106 + ([key, segments]) => [key, { segments, format: makePlainFormatter(segments) }] 107 + ) 108 + ) as Record<DateFormat, DateFormatConfig>; 109 + 110 + export interface DateOptions extends CommonOptions { 111 + message: string; 112 + format?: DateFormat; 113 + defaultValue?: Date; 114 + initialValue?: Date; 115 + minDate?: Date; 116 + maxDate?: Date; 117 + validate?: (value: Date | undefined) => string | Error | undefined; 118 + } 119 + 120 + export const date = (opts: DateOptions) => { 121 + const validate = opts.validate; 122 + const formatConfig = FORMAT_CONFIGS[opts.format ?? 'YYYY/MM/DD']; 123 + return new DatePrompt({ 124 + formatConfig, 125 + defaultValue: opts.defaultValue, 126 + initialValue: opts.initialValue, 127 + minDate: opts.minDate, 128 + maxDate: opts.maxDate, 129 + validate(value: Date | undefined) { 130 + if (value === undefined) { 131 + if (opts.defaultValue !== undefined) return undefined; 132 + if (validate) return validate(value); 133 + return 'Please enter a valid date'; 134 + } 135 + const dateOnly = (d: Date) => d.toISOString().slice(0, 10); 136 + if (opts.minDate && dateOnly(value) < dateOnly(opts.minDate)) { 137 + return settings.date.messages.afterMin(opts.minDate); 138 + } 139 + if (opts.maxDate && dateOnly(value) > dateOnly(opts.maxDate)) { 140 + return settings.date.messages.beforeMax(opts.maxDate); 141 + } 142 + if (validate) return validate(value); 143 + return undefined; 144 + }, 145 + signal: opts.signal, 146 + input: opts.input, 147 + output: opts.output, 148 + render() { 149 + const hasGuide = (opts?.withGuide ?? settings.withGuide) !== false; 150 + const titlePrefix = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} `; 151 + const title = `${titlePrefix}${opts.message}\n`; 152 + 153 + const segmentValues = this.segmentValues; 154 + const segmentCursor = this.segmentCursor; 155 + 156 + const renderState: RenderState = 157 + this.state === 'submit' 158 + ? 'submit' 159 + : this.state === 'cancel' 160 + ? 'cancel' 161 + : this.state === 'error' 162 + ? 'error' 163 + : 'active'; 164 + 165 + const userInput = renderDateFormat(segmentValues, segmentCursor, renderState, formatConfig); 166 + 167 + const value = 168 + this.value instanceof Date 169 + ? formatConfig.format({ 170 + year: String(this.value.getFullYear()).padStart(4, '0'), 171 + month: String(this.value.getMonth() + 1).padStart(2, '0'), 172 + day: String(this.value.getDate()).padStart(2, '0'), 173 + }) 174 + : ''; 175 + 176 + switch (this.state) { 177 + case 'error': { 178 + const errorText = this.error ? ` ${styleText('yellow', this.error)}` : ''; 179 + const errorPrefix = hasGuide ? `${styleText('yellow', S_BAR)} ` : ''; 180 + const errorPrefixEnd = hasGuide ? styleText('yellow', S_BAR_END) : ''; 181 + return `${title.trim()}\n${errorPrefix}${userInput}\n${errorPrefixEnd}${errorText}\n`; 182 + } 183 + case 'submit': { 184 + const valueText = value ? ` ${styleText('dim', value)}` : ''; 185 + const submitPrefix = hasGuide ? styleText('gray', S_BAR) : ''; 186 + return `${title}${submitPrefix}${valueText}`; 187 + } 188 + case 'cancel': { 189 + const valueText = value ? ` ${styleText(['strikethrough', 'dim'], value)}` : ''; 190 + const cancelPrefix = hasGuide ? styleText('gray', S_BAR) : ''; 191 + return `${title}${cancelPrefix}${valueText}${value.trim() ? `\n${cancelPrefix}` : ''}`; 192 + } 193 + default: { 194 + const defaultPrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''; 195 + const defaultPrefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : ''; 196 + const inlineErrorBar = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''; 197 + const inlineError = (this as { inlineError?: string }).inlineError 198 + ? `\n${inlineErrorBar}${styleText('yellow', (this as { inlineError: string }).inlineError)}` 199 + : ''; 200 + return `${title}${defaultPrefix}${userInput}${inlineError}\n${defaultPrefixEnd}\n`; 201 + } 202 + } 203 + }, 204 + }).prompt() as Promise<Date | symbol>; 205 + };
+1
packages/prompts/src/index.ts
··· 4 4 export * from './box.js'; 5 5 export * from './common.js'; 6 6 export * from './confirm.js'; 7 + export * from './date.js'; 7 8 export * from './group.js'; 8 9 export * from './group-multi-select.js'; 9 10 export * from './limit-options.js';
+263
packages/prompts/test/__snapshots__/date.test.ts.snap
··· 1 + // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 + 3 + exports[`date (isCI = false) > can cancel 1`] = ` 4 + [ 5 + "<cursor.hide>", 6 + "│ 7 + ◆ Pick a date 8 + │  yyy/mm/dd 9 + └ 10 + ", 11 + "<cursor.backward count=999><cursor.up count=4>", 12 + "<cursor.down count=1>", 13 + "<erase.down>", 14 + "■ Pick a date 15 + │", 16 + " 17 + ", 18 + "<cursor.show>", 19 + ] 20 + `; 21 + 22 + exports[`date (isCI = false) > defaultValue used when empty submit 1`] = ` 23 + [ 24 + "<cursor.hide>", 25 + "│ 26 + ◆ Pick a date 27 + │ 2025/12/25 28 + └ 29 + ", 30 + "<cursor.backward count=999><cursor.up count=4>", 31 + "<cursor.down count=1>", 32 + "<erase.down>", 33 + "◇ Pick a date 34 + │ 2025/12/25", 35 + " 36 + ", 37 + "<cursor.show>", 38 + ] 39 + `; 40 + 41 + exports[`date (isCI = false) > renders initial value 1`] = ` 42 + [ 43 + "<cursor.hide>", 44 + "│ 45 + ◆ Pick a date 46 + │ 2025/01/15 47 + └ 48 + ", 49 + "<cursor.backward count=999><cursor.up count=4>", 50 + "<cursor.down count=1>", 51 + "<erase.down>", 52 + "◇ Pick a date 53 + │ 2025/01/15", 54 + " 55 + ", 56 + "<cursor.show>", 57 + ] 58 + `; 59 + 60 + exports[`date (isCI = false) > renders message 1`] = ` 61 + [ 62 + "<cursor.hide>", 63 + "│ 64 + ◆ Pick a date 65 + │ 2025/01/15 66 + └ 67 + ", 68 + "<cursor.backward count=999><cursor.up count=4>", 69 + "<cursor.down count=1>", 70 + "<erase.down>", 71 + "◇ Pick a date 72 + │ 2025/01/15", 73 + " 74 + ", 75 + "<cursor.show>", 76 + ] 77 + `; 78 + 79 + exports[`date (isCI = false) > renders submitted value 1`] = ` 80 + [ 81 + "<cursor.hide>", 82 + "│ 83 + ◆ Pick a date 84 + │ 2025/06/15 85 + └ 86 + ", 87 + "<cursor.backward count=999><cursor.up count=4>", 88 + "<cursor.down count=1>", 89 + "<erase.down>", 90 + "◇ Pick a date 91 + │ 2025/06/15", 92 + " 93 + ", 94 + "<cursor.show>", 95 + ] 96 + `; 97 + 98 + exports[`date (isCI = false) > supports MM/DD/YYYY format 1`] = ` 99 + [ 100 + "<cursor.hide>", 101 + "│ 102 + ◆ Pick a date 103 + │ 01/15/2025 104 + └ 105 + ", 106 + "<cursor.backward count=999><cursor.up count=4>", 107 + "<cursor.down count=1>", 108 + "<erase.down>", 109 + "◇ Pick a date 110 + │ 01/15/2025", 111 + " 112 + ", 113 + "<cursor.show>", 114 + ] 115 + `; 116 + 117 + exports[`date (isCI = false) > withGuide: false removes guide 1`] = ` 118 + [ 119 + "<cursor.hide>", 120 + "◆ Pick a date 121 + 2025/01/15 122 + 123 + ", 124 + "<cursor.backward count=999><cursor.up count=3>", 125 + "<erase.down>", 126 + "◇ Pick a date 127 + 2025/01/15", 128 + " 129 + ", 130 + "<cursor.show>", 131 + ] 132 + `; 133 + 134 + exports[`date (isCI = true) > can cancel 1`] = ` 135 + [ 136 + "<cursor.hide>", 137 + "│ 138 + ◆ Pick a date 139 + │  yyy/mm/dd 140 + └ 141 + ", 142 + "<cursor.backward count=999><cursor.up count=4>", 143 + "<cursor.down count=1>", 144 + "<erase.down>", 145 + "■ Pick a date 146 + │", 147 + " 148 + ", 149 + "<cursor.show>", 150 + ] 151 + `; 152 + 153 + exports[`date (isCI = true) > defaultValue used when empty submit 1`] = ` 154 + [ 155 + "<cursor.hide>", 156 + "│ 157 + ◆ Pick a date 158 + │ 2025/12/25 159 + └ 160 + ", 161 + "<cursor.backward count=999><cursor.up count=4>", 162 + "<cursor.down count=1>", 163 + "<erase.down>", 164 + "◇ Pick a date 165 + │ 2025/12/25", 166 + " 167 + ", 168 + "<cursor.show>", 169 + ] 170 + `; 171 + 172 + exports[`date (isCI = true) > renders initial value 1`] = ` 173 + [ 174 + "<cursor.hide>", 175 + "│ 176 + ◆ Pick a date 177 + │ 2025/01/15 178 + └ 179 + ", 180 + "<cursor.backward count=999><cursor.up count=4>", 181 + "<cursor.down count=1>", 182 + "<erase.down>", 183 + "◇ Pick a date 184 + │ 2025/01/15", 185 + " 186 + ", 187 + "<cursor.show>", 188 + ] 189 + `; 190 + 191 + exports[`date (isCI = true) > renders message 1`] = ` 192 + [ 193 + "<cursor.hide>", 194 + "│ 195 + ◆ Pick a date 196 + │ 2025/01/15 197 + └ 198 + ", 199 + "<cursor.backward count=999><cursor.up count=4>", 200 + "<cursor.down count=1>", 201 + "<erase.down>", 202 + "◇ Pick a date 203 + │ 2025/01/15", 204 + " 205 + ", 206 + "<cursor.show>", 207 + ] 208 + `; 209 + 210 + exports[`date (isCI = true) > renders submitted value 1`] = ` 211 + [ 212 + "<cursor.hide>", 213 + "│ 214 + ◆ Pick a date 215 + │ 2025/06/15 216 + └ 217 + ", 218 + "<cursor.backward count=999><cursor.up count=4>", 219 + "<cursor.down count=1>", 220 + "<erase.down>", 221 + "◇ Pick a date 222 + │ 2025/06/15", 223 + " 224 + ", 225 + "<cursor.show>", 226 + ] 227 + `; 228 + 229 + exports[`date (isCI = true) > supports MM/DD/YYYY format 1`] = ` 230 + [ 231 + "<cursor.hide>", 232 + "│ 233 + ◆ Pick a date 234 + │ 01/15/2025 235 + └ 236 + ", 237 + "<cursor.backward count=999><cursor.up count=4>", 238 + "<cursor.down count=1>", 239 + "<erase.down>", 240 + "◇ Pick a date 241 + │ 01/15/2025", 242 + " 243 + ", 244 + "<cursor.show>", 245 + ] 246 + `; 247 + 248 + exports[`date (isCI = true) > withGuide: false removes guide 1`] = ` 249 + [ 250 + "<cursor.hide>", 251 + "◆ Pick a date 252 + 2025/01/15 253 + 254 + ", 255 + "<cursor.backward count=999><cursor.up count=3>", 256 + "<erase.down>", 257 + "◇ Pick a date 258 + 2025/01/15", 259 + " 260 + ", 261 + "<cursor.show>", 262 + ] 263 + `;
+171
packages/prompts/test/date.test.ts
··· 1 + import { updateSettings } from '@clack/core'; 2 + import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; 3 + import * as prompts from '../src/index.js'; 4 + import { MockReadable, MockWritable } from './test-utils.js'; 5 + 6 + const d = (iso: string) => { 7 + const [y, m, day] = iso.slice(0, 10).split('-').map(Number); 8 + return new Date(y, m - 1, day); 9 + }; 10 + 11 + describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { 12 + let originalCI: string | undefined; 13 + let output: MockWritable; 14 + let input: MockReadable; 15 + 16 + beforeAll(() => { 17 + originalCI = process.env.CI; 18 + process.env.CI = isCI; 19 + }); 20 + 21 + afterAll(() => { 22 + process.env.CI = originalCI; 23 + }); 24 + 25 + beforeEach(() => { 26 + output = new MockWritable(); 27 + input = new MockReadable(); 28 + }); 29 + 30 + afterEach(() => { 31 + vi.restoreAllMocks(); 32 + updateSettings({ withGuide: true }); 33 + }); 34 + 35 + test('renders message', async () => { 36 + const result = prompts.date({ 37 + message: 'Pick a date', 38 + initialValue: d('2025-01-15'), 39 + input, 40 + output, 41 + }); 42 + 43 + input.emit('keypress', undefined, { name: 'return' }); 44 + 45 + await result; 46 + 47 + expect(output.buffer).toMatchSnapshot(); 48 + }); 49 + 50 + test('renders initial value', async () => { 51 + const result = prompts.date({ 52 + message: 'Pick a date', 53 + initialValue: d('2025-01-15'), 54 + input, 55 + output, 56 + }); 57 + 58 + input.emit('keypress', undefined, { name: 'return' }); 59 + 60 + const value = await result; 61 + 62 + expect(value).toBeInstanceOf(Date); 63 + expect((value as Date).toISOString().slice(0, 10)).toBe('2025-01-15'); 64 + expect(output.buffer).toMatchSnapshot(); 65 + }); 66 + 67 + test('can cancel', async () => { 68 + const result = prompts.date({ 69 + message: 'Pick a date', 70 + input, 71 + output, 72 + }); 73 + 74 + input.emit('keypress', 'escape', { name: 'escape' }); 75 + 76 + const value = await result; 77 + 78 + expect(prompts.isCancel(value)).toBe(true); 79 + expect(output.buffer).toMatchSnapshot(); 80 + }); 81 + 82 + test('renders submitted value', async () => { 83 + const result = prompts.date({ 84 + message: 'Pick a date', 85 + initialValue: d('2025-06-15'), 86 + input, 87 + output, 88 + }); 89 + 90 + input.emit('keypress', undefined, { name: 'return' }); 91 + 92 + const value = await result; 93 + 94 + expect(value).toBeInstanceOf(Date); 95 + expect((value as Date).toISOString().slice(0, 10)).toBe('2025-06-15'); 96 + expect(output.buffer).toMatchSnapshot(); 97 + }); 98 + 99 + test('defaultValue used when empty submit', async () => { 100 + const result = prompts.date({ 101 + message: 'Pick a date', 102 + defaultValue: d('2025-12-25'), 103 + input, 104 + output, 105 + }); 106 + 107 + input.emit('keypress', undefined, { name: 'return' }); 108 + 109 + const value = await result; 110 + 111 + expect(value).toBeInstanceOf(Date); 112 + expect((value as Date).toISOString().slice(0, 10)).toBe('2025-12-25'); 113 + expect(output.buffer).toMatchSnapshot(); 114 + }); 115 + 116 + test('withGuide: false removes guide', async () => { 117 + const result = prompts.date({ 118 + message: 'Pick a date', 119 + withGuide: false, 120 + initialValue: d('2025-01-15'), 121 + input, 122 + output, 123 + }); 124 + 125 + input.emit('keypress', undefined, { name: 'return' }); 126 + 127 + await result; 128 + 129 + expect(output.buffer).toMatchSnapshot(); 130 + }); 131 + 132 + test('supports MM/DD/YYYY format', async () => { 133 + const result = prompts.date({ 134 + message: 'Pick a date', 135 + format: 'MM/DD/YYYY', 136 + initialValue: d('2025-01-15'), 137 + input, 138 + output, 139 + }); 140 + 141 + input.emit('keypress', undefined, { name: 'return' }); 142 + 143 + const value = await result; 144 + 145 + expect(value).toBeInstanceOf(Date); 146 + expect((value as Date).toISOString().slice(0, 10)).toBe('2025-01-15'); 147 + expect(output.buffer).toMatchSnapshot(); 148 + }); 149 + 150 + test('minDate shows error when date before min and submit', async () => { 151 + const result = prompts.date({ 152 + message: 'Pick a date', 153 + initialValue: d('2025-01-10'), 154 + minDate: d('2025-01-15'), 155 + input, 156 + output, 157 + }); 158 + 159 + input.emit('keypress', undefined, { name: 'return' }); 160 + await new Promise((r) => setImmediate(r)); 161 + 162 + const hasError = output.buffer.some( 163 + (s) => typeof s === 'string' && s.includes('Date must be on or after') 164 + ); 165 + expect(hasError).toBe(true); 166 + 167 + input.emit('keypress', 'escape', { name: 'escape' }); 168 + const value = await result; 169 + expect(prompts.isCancel(value)).toBe(true); 170 + }); 171 + });