[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.

ref(dates): adjust date interface (#487)

Co-authored-by: HarshaVardhan <40317596+harshaktg@users.noreply.github.com>
Co-authored-by: James Garbutt <43081j@users.noreply.github.com>

authored by

Nate Moore
HarshaVardhan
James Garbutt
and committed by
GitHub
(Mar 17, 2026, 2:55 PM -0500) fe32e137 bdf89a5f

+498 -578
-6
.changeset/early-ads-tap.md
··· 1 - --- 2 - "@clack/prompts": patch 3 - "@clack/core": patch 4 - --- 5 - 6 - Fix timezone issues in DatePrompt causing dates to be off by one day in non-UTC timezones
+1 -1
.changeset/tangy-mirrors-hug.md
··· 3 3 "@clack/core": minor 4 4 --- 5 5 6 - Adds `date` prompt with format support (YYYY/MM/DD, MM/DD/YYYY, DD/MM/YYYY). 6 + Adds `date` prompt with `format` support (YMD, MDY, DMY)
+3 -3
examples/basic/date.ts
··· 4 4 async function main() { 5 5 const result = (await p.date({ 6 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'), 7 + format: 'YMD', 8 + minDate: new Date('2026-01-01'), 9 + maxDate: new Date('2026-12-31'), 10 10 })) as Date; 11 11 12 12 if (p.isCancel(result)) {
+1 -1
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'; 5 + export type { DateFormat, DateOptions, DateParts } from './prompts/date.js'; 6 6 export { default as DatePrompt } from './prompts/date.js'; 7 7 export type { GroupMultiSelectOptions } from './prompts/group-multiselect.js'; 8 8 export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect.js';
+253 -247
packages/core/src/prompts/date.ts
··· 13 13 day: string; 14 14 } 15 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 }; 16 + export type DateFormat = 'YMD' | 'MDY' | 'DMY'; 17 + 18 + const SEGMENTS: Record<string, SegmentConfig> = { Y: { type: 'year', len: 4 }, M: { type: 'month', len: 2 }, D: { type: 'day', len: 2 } } as const; 19 + 20 + function segmentsFor(fmt: DateFormat): SegmentConfig[] { 21 + return [...fmt].map((c) => SEGMENTS[c as keyof typeof SEGMENTS]); 22 22 } 23 23 24 - function clamp(min: number, value: number, max: number): number { 25 - return Math.max(min, Math.min(max, value)); 24 + function detectLocaleFormat(locale?: string): { segments: SegmentConfig[]; separator: string } { 25 + const fmt = new Intl.DateTimeFormat(locale, { 26 + year: 'numeric', 27 + month: '2-digit', 28 + day: '2-digit', 29 + }); 30 + const parts = fmt.formatToParts(new Date(2000, 0, 15)); 31 + const segments: SegmentConfig[] = []; 32 + let separator = '/'; 33 + for (const p of parts) { 34 + if (p.type === 'literal') { 35 + separator = p.value.trim() || p.value; 36 + } else if (p.type === 'year' || p.type === 'month' || p.type === 'day') { 37 + segments.push({ type: p.type, len: p.type === 'year' ? 4 : 2 }); 38 + } 39 + } 40 + return { segments, separator }; 26 41 } 27 42 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.getUTCFullYear()).padStart(4, '0'), 35 - month: String(date.getUTCMonth() + 1).padStart(2, '0'), 36 - day: String(date.getUTCDate()).padStart(2, '0'), 37 - }; 43 + /** Parse string segment values to numbers, treating blanks as 0 */ 44 + function parseSegmentToNum(s: string): number { 45 + return Number.parseInt((s || '0').replace(/_/g, '0'), 10) || 0; 38 46 } 39 47 40 - function segmentValuesToParsed(parts: DateParts): { 41 - year: number; 42 - month: number; 43 - day: number; 44 - } { 45 - const val = (s: string) => Number.parseInt((s || '0').replace(/_/g, '0'), 10) || 0; 46 - return { 47 - year: val(parts.year), 48 - month: val(parts.month), 49 - day: val(parts.day), 50 - }; 48 + function parse(parts: DateParts): { year: number; month: number; day: number } { 49 + return { year: parseSegmentToNum(parts.year), month: parseSegmentToNum(parts.month), day: parseSegmentToNum(parts.day) }; 51 50 } 52 51 53 52 function daysInMonth(year: number, month: number): number { 54 53 return new Date(year || 2001, month || 1, 0).getDate(); 55 54 } 56 55 57 - function clampSegment( 58 - value: number, 59 - type: 'year' | 'month' | 'day', 60 - context: { year: number; month: number } 61 - ): number { 62 - if (type === 'year') { 63 - return clamp(1000, value || 1000, 9999); 64 - } 65 - if (type === 'month') { 66 - return clamp(1, value || 1, 12); 67 - } 68 - const { year, month } = context; 69 - return clamp(1, value || 1, daysInMonth(year, month)); 70 - } 71 - 72 - function datePartsUTC(d: Date): { year: number; month: number; day: number } { 73 - return { 74 - year: d.getUTCFullYear(), 75 - month: d.getUTCMonth() + 1, 76 - day: d.getUTCDate(), 77 - }; 78 - } 79 - 80 - function getSegmentBounds( 81 - type: 'year' | 'month' | 'day', 82 - context: { year: number; month: number; day: number }, 83 - minDate: Date | undefined, 84 - maxDate: Date | undefined 85 - ): { min: number; max: number } { 86 - const { year, month } = context; 87 - const minParts = minDate ? datePartsUTC(minDate) : null; 88 - const maxParts = maxDate ? datePartsUTC(maxDate) : null; 89 - 90 - if (type === 'year') { 91 - const min = minParts ? minParts.year : 1000; 92 - const max = maxParts ? maxParts.year : 9999; 93 - return { min, max }; 94 - } 95 - if (type === 'month') { 96 - let min = 1; 97 - let max = 12; 98 - if (minParts && year && year >= minParts.year) { 99 - if (year === minParts.year) min = minParts.month; 100 - } 101 - if (maxParts && year && year <= maxParts.year) { 102 - if (year === maxParts.year) max = maxParts.month; 103 - } 104 - return { min, max }; 105 - } 106 - // day 107 - let min = 1; 108 - let max = daysInMonth(year, month); 109 - if (minParts && year && month && year === minParts.year && month === minParts.month) { 110 - min = minParts.day; 111 - } 112 - if (maxParts && year && month && year === maxParts.year && month === maxParts.month) { 113 - max = maxParts.day; 114 - } 115 - return { min, max }; 116 - } 117 - 118 - /** Parse segment values to calendar parts; returns undefined if invalid. */ 119 - function segmentValuesToParts( 56 + /** Validate and return calendar parts, or undefined if invalid */ 57 + function validParts( 120 58 parts: DateParts 121 59 ): { year: number; month: number; day: number } | undefined { 122 - const { year, month, day } = segmentValuesToParsed(parts); 123 - if (!year || year < 1000 || year > 9999) return undefined; 60 + const { year, month, day } = parse(parts); 61 + if (!year || year < 0 || year > 9999) return undefined; 124 62 if (!month || month < 1 || month > 12) return undefined; 125 63 if (!day || day < 1) return undefined; 126 - const date = new Date(Date.UTC(year, month - 1, day)); 127 - if ( 128 - date.getUTCFullYear() !== year || 129 - date.getUTCMonth() !== month - 1 || 130 - date.getUTCDate() !== day 131 - ) { 64 + const d = new Date(Date.UTC(year, month - 1, day)); 65 + if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) 132 66 return undefined; 133 - } 134 67 return { year, month, day }; 135 68 } 136 69 137 - /** Build a Date from segment values using UTC midnight so getFullYear/getMonth/getDate are timezone-stable. */ 138 - function segmentValuesToDate(parts: DateParts): Date | undefined { 139 - const parsed = segmentValuesToParts(parts); 140 - if (!parsed) return undefined; 141 - return new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day)); 70 + function toDate(parts: DateParts): Date | undefined { 71 + const p = validParts(parts); 72 + return p ? new Date(Date.UTC(p.year, p.month - 1, p.day)) : undefined; 142 73 } 143 74 144 - function segmentValuesToISOString(parts: DateParts): string | undefined { 145 - const parsed = segmentValuesToParts(parts); 146 - if (!parsed) return undefined; 147 - return new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day)).toISOString().slice(0, 10); 148 - } 75 + function segmentBounds( 76 + type: 'year' | 'month' | 'day', 77 + ctx: { year: number; month: number }, 78 + minDate: Date | undefined, 79 + maxDate: Date | undefined 80 + ): { min: number; max: number } { 81 + const minP = minDate 82 + ? { year: minDate.getUTCFullYear(), month: minDate.getUTCMonth() + 1, day: minDate.getUTCDate() } 83 + : null; 84 + const maxP = maxDate 85 + ? { year: maxDate.getUTCFullYear(), month: maxDate.getUTCMonth() + 1, day: maxDate.getUTCDate() } 86 + : null; 149 87 150 - function getSegmentValidationMessage(parts: DateParts, seg: SegmentConfig): string | undefined { 151 - const { year, month, day } = segmentValuesToParsed(parts); 152 - if (seg.type === 'month' && (month < 1 || month > 12)) { 153 - return settings.date.messages.invalidMonth; 88 + if (type === 'year') { 89 + return { min: minP?.year ?? 1, max: maxP?.year ?? 9999 }; 154 90 } 155 - if (seg.type === 'day') { 156 - if (day < 1) return undefined; 157 - if (day > daysInMonth(year, month)) { 158 - const monthName = 159 - month >= 1 && month <= 12 ? settings.date.monthNames[month - 1] : 'this month'; 160 - return settings.date.messages.invalidDay(daysInMonth(year, month), monthName); 161 - } 91 + if (type === 'month') { 92 + return { 93 + min: minP && ctx.year === minP.year ? minP.month : 1, 94 + max: maxP && ctx.year === maxP.year ? maxP.month : 12, 95 + }; 162 96 } 163 - return undefined; 97 + return { 98 + min: minP && ctx.year === minP.year && ctx.month === minP.month ? minP.day : 1, 99 + max: maxP && ctx.year === maxP.year && ctx.month === maxP.month ? maxP.day : daysInMonth(ctx.year, ctx.month), 100 + }; 164 101 } 165 102 166 103 export interface DateOptions extends PromptOptions<Date, DatePrompt> { 167 - formatConfig: DateFormatConfig; 104 + format?: DateFormat; 105 + locale?: string; 106 + separator?: string; 168 107 defaultValue?: Date; 169 108 initialValue?: Date; 170 109 minDate?: Date; ··· 172 111 } 173 112 174 113 export default class DatePrompt extends Prompt<Date> { 175 - #config: DateFormatConfig; 114 + #segments: SegmentConfig[]; 115 + #separator: string; 176 116 #segmentValues: DateParts; 177 117 #minDate: Date | undefined; 178 118 #maxDate: Date | undefined; 179 119 #cursor = { segmentIndex: 0, positionInSegment: 0 }; 120 + #segmentSelected = true; 121 + #pendingTensDigit: string | null = null; 180 122 181 - /** Inline validation message shown beneath input during editing */ 182 123 inlineError = ''; 183 124 184 - #refreshFromSegmentValues() { 185 - const display = this.#config.format(this.#segmentValues); 186 - this._setUserInput(display); 187 - this._setValue(segmentValuesToDate(this.#segmentValues) ?? undefined); 188 - } 189 - 190 125 get segmentCursor() { 191 126 return { ...this.#cursor }; 192 127 } ··· 195 130 return { ...this.#segmentValues }; 196 131 } 197 132 133 + get segments(): readonly SegmentConfig[] { 134 + return this.#segments; 135 + } 136 + 137 + get separator(): string { 138 + return this.#separator; 139 + } 140 + 141 + get formattedValue(): string { 142 + return this.#format(this.#segmentValues); 143 + } 144 + 145 + #format(parts: DateParts): string { 146 + return this.#segments.map((s) => parts[s.type]).join(this.#separator); 147 + } 148 + 149 + #refresh() { 150 + this._setUserInput(this.#format(this.#segmentValues)); 151 + this._setValue(toDate(this.#segmentValues) ?? undefined); 152 + } 153 + 198 154 constructor(opts: DateOptions) { 199 - const config = opts.formatConfig; 155 + const detected = opts.format 156 + ? { segments: segmentsFor(opts.format), separator: opts.separator ?? '/' } 157 + : detectLocaleFormat(opts.locale); 158 + const sep = opts.separator ?? detected.separator; 159 + const segments = opts.format ? segmentsFor(opts.format) : detected.segments; 160 + 200 161 const initialDate = opts.initialValue ?? opts.defaultValue; 201 - const segmentValues = dateToSegmentValues(initialDate); 202 - const initialDisplay = config.format(segmentValues); 162 + const segmentValues: DateParts = initialDate 163 + ? { 164 + year: String(initialDate.getUTCFullYear()).padStart(4, '0'), 165 + month: String(initialDate.getUTCMonth() + 1).padStart(2, '0'), 166 + day: String(initialDate.getUTCDate()).padStart(2, '0'), 167 + } 168 + : { year: '____', month: '__', day: '__' }; 169 + 170 + const initialDisplay = segments.map((s) => segmentValues[s.type]).join(sep); 203 171 204 - super( 205 - { 206 - ...opts, 207 - initialUserInput: initialDisplay, 208 - }, 209 - false 210 - ); 211 - this.#config = config; 172 + super({ ...opts, initialUserInput: initialDisplay }, false); 173 + this.#segments = segments; 174 + this.#separator = sep; 212 175 this.#segmentValues = segmentValues; 213 176 this.#minDate = opts.minDate; 214 177 this.#maxDate = opts.maxDate; 215 - this.#refreshFromSegmentValues(); 178 + this.#refresh(); 216 179 217 180 this.on('cursor', (key) => this.#onCursor(key)); 218 181 this.on('key', (char, key) => this.#onKey(char, key)); 219 182 this.on('finalize', () => this.#onFinalize(opts)); 220 183 } 221 184 222 - #getCurrentSegment(): { segment: SegmentConfig; index: number } | undefined { 223 - const index = clamp(0, this.#cursor.segmentIndex, this.#config.segments.length - 1); 224 - const segment = this.#config.segments[index]; 185 + #seg(): { segment: SegmentConfig; index: number } | undefined { 186 + const index = Math.max(0, Math.min(this.#cursor.segmentIndex, this.#segments.length - 1)); 187 + const segment = this.#segments[index]; 225 188 if (!segment) return undefined; 226 - this.#cursor.positionInSegment = clamp(0, this.#cursor.positionInSegment, segment.len - 1); 189 + this.#cursor.positionInSegment = Math.max(0, Math.min(this.#cursor.positionInSegment, segment.len - 1)); 227 190 return { segment, index }; 228 191 } 229 192 230 - #moveCursorNext() { 193 + #navigate(direction: 1 | -1) { 231 194 this.inlineError = ''; 232 - const ctx = this.#getCurrentSegment(); 195 + this.#pendingTensDigit = null; 196 + const ctx = this.#seg(); 233 197 if (!ctx) return; 234 - const newPos = this.#cursor.positionInSegment + 1; 235 - if (newPos < ctx.segment.len) { 236 - this.#cursor.positionInSegment = newPos; 237 - return; 238 - } 239 - const newIndex = Math.min(this.#config.segments.length - 1, ctx.index + 1); 240 - this.#cursor.segmentIndex = newIndex; 198 + this.#cursor.segmentIndex = Math.max(0, Math.min(this.#segments.length - 1, ctx.index + direction)); 241 199 this.#cursor.positionInSegment = 0; 200 + this.#segmentSelected = true; 242 201 } 243 202 244 - #moveCursorPrevious() { 245 - this.inlineError = ''; 246 - const ctx = this.#getCurrentSegment(); 203 + #adjust(direction: 1 | -1) { 204 + const ctx = this.#seg(); 247 205 if (!ctx) return; 248 - const newPos = this.#cursor.positionInSegment - 1; 249 - if (newPos >= 0) { 250 - this.#cursor.positionInSegment = newPos; 251 - return; 252 - } 253 - const newIndex = Math.max(0, ctx.index - 1); 254 - this.#cursor.segmentIndex = newIndex; 255 - this.#cursor.positionInSegment = 0; 256 - } 257 - 258 - #incrementSegment() { 259 - const ctx = this.#getCurrentSegment(); 260 - if (!ctx) return; 261 - this.#adjustSegment(ctx, 1); 262 - } 263 - 264 - #decrementSegment() { 265 - const ctx = this.#getCurrentSegment(); 266 - if (!ctx) return; 267 - this.#adjustSegment(ctx, -1); 268 - } 269 - 270 - #adjustSegment(ctx: { segment: SegmentConfig; index: number }, direction: 1 | -1) { 271 206 const { segment } = ctx; 272 207 const raw = this.#segmentValues[segment.type]; 273 208 const isBlank = !raw || raw.replace(/_/g, '') === ''; 274 209 const num = Number.parseInt((raw || '0').replace(/_/g, '0'), 10) || 0; 275 - const bounds = getSegmentBounds( 276 - segment.type, 277 - segmentValuesToParsed(this.#segmentValues), 278 - this.#minDate, 279 - this.#maxDate 280 - ); 210 + const bounds = segmentBounds(segment.type, parse(this.#segmentValues), this.#minDate, this.#maxDate); 281 211 282 - const newNum = isBlank 283 - ? direction === 1 284 - ? bounds.min 285 - : bounds.max 286 - : clamp(bounds.min, num + direction, bounds.max); 212 + let next: number; 213 + if (isBlank) { 214 + next = direction === 1 ? bounds.min : bounds.max; 215 + } else { 216 + next = Math.max(Math.min(bounds.max, num + direction), bounds.min); 217 + } 287 218 288 - const newSegmentValue = String(newNum).padStart(segment.len, '0'); 289 - this.#segmentValues = { 290 - ...this.#segmentValues, 291 - [segment.type]: newSegmentValue, 292 - }; 293 - this.#refreshFromSegmentValues(); 219 + this.#segmentValues = { ...this.#segmentValues, [segment.type]: next.toString().padStart(segment.len, '0') }; 220 + this.#segmentSelected = true; 221 + this.#pendingTensDigit = null; 222 + this.#refresh(); 294 223 } 295 224 296 225 #onCursor(key?: string) { 297 226 if (!key) return; 298 227 switch (key) { 299 - case 'right': 300 - return this.#moveCursorNext(); 301 - case 'left': 302 - return this.#moveCursorPrevious(); 303 - case 'up': 304 - return this.#incrementSegment(); 305 - case 'down': 306 - return this.#decrementSegment(); 228 + case 'right': return this.#navigate(1); 229 + case 'left': return this.#navigate(-1); 230 + case 'up': return this.#adjust(1); 231 + case 'down': return this.#adjust(-1); 307 232 } 308 233 } 309 234 310 235 #onKey(char: string | undefined, key: Key) { 236 + // Backspace 311 237 const isBackspace = 312 - key?.name === 'backspace' || 313 - key?.sequence === '\x7f' || 314 - key?.sequence === '\b' || 315 - char === '\x7f' || 316 - char === '\b'; 238 + key?.name === 'backspace' || key?.sequence === '\x7f' || key?.sequence === '\b' || 239 + char === '\x7f' || char === '\b'; 317 240 if (isBackspace) { 318 241 this.inlineError = ''; 319 - const ctx = this.#getCurrentSegment(); 242 + const ctx = this.#seg(); 320 243 if (!ctx) return; 321 - const { segment } = ctx; 322 - const segmentVal = this.#segmentValues[segment.type]; 323 - if (!segmentVal.replace(/_/g, '')) return; 324 - 325 - this.#segmentValues[segment.type] = '_'.repeat(segment.len); 326 - this.#refreshFromSegmentValues(); 244 + if (!this.#segmentValues[ctx.segment.type].replace(/_/g, '')) { 245 + this.#navigate(-1); 246 + return; 247 + } 248 + this.#segmentValues[ctx.segment.type] = '_'.repeat(ctx.segment.len); 249 + this.#segmentSelected = true; 327 250 this.#cursor.positionInSegment = 0; 251 + this.#refresh(); 328 252 return; 329 253 } 330 254 255 + // Tab navigation 256 + if (key?.name === 'tab') { 257 + this.inlineError = ''; 258 + const ctx = this.#seg(); 259 + if (!ctx) return; 260 + const dir = key.shift ? -1 : 1; 261 + const next = ctx.index + dir; 262 + if (next >= 0 && next < this.#segments.length) { 263 + this.#cursor.segmentIndex = next; 264 + this.#cursor.positionInSegment = 0; 265 + this.#segmentSelected = true; 266 + } 267 + return; 268 + } 269 + 270 + // Digit input 331 271 if (char && /^[0-9]$/.test(char)) { 332 - const ctx = this.#getCurrentSegment(); 272 + const ctx = this.#seg(); 333 273 if (!ctx) return; 334 274 const { segment } = ctx; 335 - const segmentDisplay = this.#segmentValues[segment.type]; 275 + const isBlank = !this.#segmentValues[segment.type].replace(/_/g, ''); 276 + 277 + // Pending tens digit: complete the two-digit entry 278 + if (this.#segmentSelected && this.#pendingTensDigit !== null && !isBlank) { 279 + const newVal = this.#pendingTensDigit + char; 280 + const newParts = { ...this.#segmentValues, [segment.type]: newVal }; 281 + const err = this.#validateSegment(newParts, segment); 282 + if (err) { 283 + this.inlineError = err; 284 + this.#pendingTensDigit = null; 285 + this.#segmentSelected = false; 286 + return; 287 + } 288 + this.inlineError = ''; 289 + this.#segmentValues[segment.type] = newVal; 290 + this.#pendingTensDigit = null; 291 + this.#segmentSelected = false; 292 + this.#refresh(); 293 + if (ctx.index < this.#segments.length - 1) { 294 + this.#cursor.segmentIndex = ctx.index + 1; 295 + this.#cursor.positionInSegment = 0; 296 + this.#segmentSelected = true; 297 + } 298 + return; 299 + } 336 300 337 - const firstBlank = segmentDisplay.indexOf('_'); 338 - const pos = 339 - firstBlank >= 0 ? firstBlank : Math.min(this.#cursor.positionInSegment, segment.len - 1); 301 + // Clear-on-type: typing into a selected filled segment clears it first 302 + if (this.#segmentSelected && !isBlank) { 303 + this.#segmentValues[segment.type] = '_'.repeat(segment.len); 304 + this.#cursor.positionInSegment = 0; 305 + } 306 + this.#segmentSelected = false; 307 + this.#pendingTensDigit = null; 308 + 309 + const display = this.#segmentValues[segment.type]; 310 + const firstBlank = display.indexOf('_'); 311 + const pos = firstBlank >= 0 ? firstBlank : Math.min(this.#cursor.positionInSegment, segment.len - 1); 340 312 if (pos < 0 || pos >= segment.len) return; 341 313 342 - const newSegmentVal = segmentDisplay.slice(0, pos) + char + segmentDisplay.slice(pos + 1); 314 + let newVal = display.slice(0, pos) + char + display.slice(pos + 1); 315 + 316 + // Smart digit placement 317 + let shouldStaySelected = false; 318 + if (pos === 0 && display === '__' && (segment.type === 'month' || segment.type === 'day')) { 319 + const digit = Number.parseInt(char, 10); 320 + newVal = `0${char}`; 321 + shouldStaySelected = digit <= (segment.type === 'month' ? 1 : 2); 322 + } 323 + if (segment.type === 'year') { 324 + const digits = display.replace(/_/g, ''); 325 + newVal = (digits + char).padStart(segment.len, '_'); 326 + } 343 327 344 - if (!newSegmentVal.includes('_')) { 345 - const newParts = { 346 - ...this.#segmentValues, 347 - [segment.type]: newSegmentVal, 348 - }; 349 - const validationMsg = getSegmentValidationMessage(newParts, segment); 350 - if (validationMsg) { 351 - this.inlineError = validationMsg; 328 + if (!newVal.includes('_')) { 329 + const newParts = { ...this.#segmentValues, [segment.type]: newVal }; 330 + const err = this.#validateSegment(newParts, segment); 331 + if (err) { 332 + this.inlineError = err; 352 333 return; 353 334 } 354 335 } 355 336 this.inlineError = ''; 356 337 357 - this.#segmentValues[segment.type] = newSegmentVal; 358 - const iso = segmentValuesToISOString(this.#segmentValues); 338 + this.#segmentValues[segment.type] = newVal; 359 339 360 - if (iso) { 361 - const { year, month, day } = segmentValuesToParsed(this.#segmentValues); 340 + // Clamp only when the current segment is fully entered 341 + const parsed = !newVal.includes('_') ? validParts(this.#segmentValues) : undefined; 342 + if (parsed) { 343 + const { year, month } = parsed; 344 + const maxDay = daysInMonth(year, month); 362 345 this.#segmentValues = { 363 - year: String(clampSegment(year, 'year', { year, month })).padStart(4, '0'), 364 - month: String(clampSegment(month, 'month', { year, month })).padStart(2, '0'), 365 - day: String(clampSegment(day, 'day', { year, month })).padStart(2, '0'), 346 + year: String(Math.max(0, Math.min(9999, year))).padStart(4, '0'), 347 + month: String(Math.max(1, Math.min(12, month))).padStart(2, '0'), 348 + day: String(Math.max(1, Math.min(maxDay, parsed.day))).padStart(2, '0'), 366 349 }; 367 350 } 368 - this.#refreshFromSegmentValues(); 351 + this.#refresh(); 369 352 370 - const nextBlank = newSegmentVal.indexOf('_'); 371 - const wasFilling = firstBlank >= 0; 372 - if (nextBlank >= 0) { 353 + // Advance cursor 354 + const nextBlank = newVal.indexOf('_'); 355 + if (shouldStaySelected) { 356 + this.#segmentSelected = true; 357 + this.#pendingTensDigit = char; 358 + } else if (nextBlank >= 0) { 373 359 this.#cursor.positionInSegment = nextBlank; 374 - } else if (wasFilling && ctx.index < this.#config.segments.length - 1) { 360 + } else if (firstBlank >= 0 && ctx.index < this.#segments.length - 1) { 375 361 this.#cursor.segmentIndex = ctx.index + 1; 376 362 this.#cursor.positionInSegment = 0; 363 + this.#segmentSelected = true; 377 364 } else { 378 365 this.#cursor.positionInSegment = Math.min(pos + 1, segment.len - 1); 379 366 } 380 367 } 381 368 } 382 369 370 + #validateSegment(parts: DateParts, seg: SegmentConfig): string | undefined { 371 + const { month, day } = parse(parts); 372 + if (seg.type === 'month' && (month < 0 || month > 12)) { 373 + return settings.date.messages.invalidMonth; 374 + } 375 + if (seg.type === 'day' && (day < 0 || day > 31)) { 376 + return settings.date.messages.invalidDay(31, 'any month'); 377 + } 378 + return undefined; 379 + } 380 + 383 381 #onFinalize(opts: DateOptions) { 384 - this.value = segmentValuesToDate(this.#segmentValues) ?? opts.defaultValue ?? undefined; 382 + const { year, month, day } = parse(this.#segmentValues); 383 + if (year && month && day) { 384 + const maxDay = daysInMonth(year, month); 385 + this.#segmentValues = { 386 + ...this.#segmentValues, 387 + day: String(Math.min(day, maxDay)).padStart(2, '0'), 388 + }; 389 + } 390 + this.value = toDate(this.#segmentValues) ?? opts.defaultValue ?? undefined; 385 391 } 386 392 }
+7
packages/core/src/utils/settings.ts
··· 29 29 monthNames: string[]; 30 30 messages: { 31 31 invalidMonth: string; 32 + required: string; 32 33 invalidDay: (days: number, month: string) => string; 33 34 afterMin: (min: Date) => string; 34 35 beforeMax: (max: Date) => string; ··· 56 57 date: { 57 58 monthNames: [...DEFAULT_MONTH_NAMES], 58 59 messages: { 60 + required: 'Please enter a valid date', 59 61 invalidMonth: 'There are only 12 months in a year', 60 62 invalidDay: (days, month) => `There are only ${days} days in ${month}`, 61 63 afterMin: (min) => `Date must be on or after ${min.toISOString().slice(0, 10)}`, ··· 99 101 /** Month names for validation messages (January, February, ...) */ 100 102 monthNames?: string[]; 101 103 messages?: { 104 + /** Shown when date is missing */ 105 + required?: string; 102 106 /** Shown when month > 12 */ 103 107 invalidMonth?: string; 104 108 /** (days, monthName) => message for invalid day */ ··· 147 151 settings.date.monthNames = [...date.monthNames]; 148 152 } 149 153 if (date.messages !== undefined) { 154 + if (date.messages.required !== undefined) { 155 + settings.date.messages.required = date.messages.required; 156 + } 150 157 if (date.messages.invalidMonth !== undefined) { 151 158 settings.date.messages.invalidMonth = date.messages.invalidMonth; 152 159 }
+139 -141
packages/core/test/prompts/date.test.ts
··· 1 1 import { cursor } from 'sisteransi'; 2 2 import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; 3 - import type { DateFormatConfig, DateParts } from '../../src/prompts/date.js'; 4 3 import { default as DatePrompt } from '../../src/prompts/date.js'; 5 4 import { isCancel } from '../../src/utils/index.js'; 6 5 import { MockReadable } from '../mock-readable.js'; 7 6 import { MockWritable } from '../mock-writable.js'; 8 7 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 8 const d = (iso: string) => { 34 9 const [y, m, day] = iso.slice(0, 10).split('-').map(Number); 35 10 return new Date(Date.UTC(y, m - 1, day)); ··· 53 28 input, 54 29 output, 55 30 render: () => 'foo', 56 - formatConfig: YYYY_MM_DD, 31 + format: 'YMD', 57 32 }); 58 33 instance.prompt(); 59 34 expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); ··· 64 39 input, 65 40 output, 66 41 render: () => 'foo', 67 - formatConfig: YYYY_MM_DD, 42 + format: 'YMD', 68 43 initialValue: d('2025-01-15'), 69 44 }); 70 45 instance.prompt(); 71 46 expect(instance.userInput).to.equal('2025/01/15'); 72 47 expect(instance.value).toBeInstanceOf(Date); 73 - expect(instance.value?.toISOString().slice(0, 10)).to.equal('2025-01-15'); 48 + expect(instance.value!.toISOString().slice(0, 10)).to.equal('2025-01-15'); 74 49 }); 75 50 76 51 test('left/right navigates between segments', () => { ··· 78 53 input, 79 54 output, 80 55 render: () => 'foo', 81 - formatConfig: YYYY_MM_DD, 56 + format: 'YMD', 82 57 initialValue: d('2025-01-15'), 83 58 }); 84 59 instance.prompt(); 85 - expect(instance.segmentCursor).to.deep.equal({ 86 - segmentIndex: 0, 87 - positionInSegment: 0, 88 - }); 89 - // Move within year (0->1->2->3), then right from end goes to month 90 - for (let i = 0; i < 4; i++) { 91 - input.emit('keypress', undefined, { name: 'right' }); 92 - } 93 - expect(instance.segmentCursor).to.deep.equal({ 94 - segmentIndex: 1, 95 - positionInSegment: 0, 96 - }); 97 - for (let i = 0; i < 2; i++) { 98 - input.emit('keypress', undefined, { name: 'right' }); 99 - } 100 - expect(instance.segmentCursor).to.deep.equal({ 101 - segmentIndex: 2, 102 - positionInSegment: 0, 103 - }); 60 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); 61 + input.emit('keypress', undefined, { name: 'right' }); 62 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); 63 + input.emit('keypress', undefined, { name: 'right' }); 64 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 2, positionInSegment: 0 }); 104 65 input.emit('keypress', undefined, { name: 'left' }); 105 - expect(instance.segmentCursor).to.deep.equal({ 106 - segmentIndex: 1, 107 - positionInSegment: 0, 108 - }); 66 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); 109 67 }); 110 68 111 69 test('up/down increments and decrements segment', () => { ··· 113 71 input, 114 72 output, 115 73 render: () => 'foo', 116 - formatConfig: YYYY_MM_DD, 74 + format: 'YMD', 117 75 initialValue: d('2025-01-15'), 118 76 }); 119 77 instance.prompt(); 120 - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month 78 + input.emit('keypress', undefined, { name: 'right' }); // move to month 121 79 input.emit('keypress', undefined, { name: 'up' }); 122 80 expect(instance.userInput).to.equal('2025/02/15'); 123 81 input.emit('keypress', undefined, { name: 'down' }); ··· 129 87 input, 130 88 output, 131 89 render: () => 'foo', 132 - formatConfig: YYYY_MM_DD, 90 + format: 'YMD', 133 91 }); 134 92 instance.prompt(); 135 93 expect(instance.userInput).to.equal('____/__/__'); 136 94 input.emit('keypress', undefined, { name: 'up' }); // up on year (first segment) 137 - expect(instance.userInput).to.equal('1000/__/__'); 138 - input.emit('keypress', undefined, { name: 'right' }); 139 - input.emit('keypress', undefined, { name: 'right' }); 140 - input.emit('keypress', undefined, { name: 'right' }); 95 + expect(instance.userInput).to.equal('0001/__/__'); 141 96 input.emit('keypress', undefined, { name: 'right' }); // move to month 142 97 input.emit('keypress', undefined, { name: 'up' }); 143 - expect(instance.userInput).to.equal('1000/01/__'); 98 + expect(instance.userInput).to.equal('0001/01/__'); 144 99 }); 145 100 146 101 test('with minDate/maxDate, up on blank segment starts at min', () => { ··· 148 103 input, 149 104 output, 150 105 render: () => 'foo', 151 - formatConfig: YYYY_MM_DD, 106 + format: 'YMD', 152 107 minDate: d('2025-03-10'), 153 108 maxDate: d('2025-11-20'), 154 109 }); ··· 156 111 expect(instance.userInput).to.equal('____/__/__'); 157 112 input.emit('keypress', undefined, { name: 'up' }); 158 113 expect(instance.userInput).to.equal('2025/__/__'); 159 - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); 114 + input.emit('keypress', undefined, { name: 'right' }); 160 115 input.emit('keypress', undefined, { name: 'up' }); 161 116 expect(instance.userInput).to.equal('2025/03/__'); 162 - for (let i = 0; i < 2; i++) input.emit('keypress', undefined, { name: 'right' }); 117 + input.emit('keypress', undefined, { name: 'right' }); 163 118 input.emit('keypress', undefined, { name: 'up' }); 164 119 expect(instance.userInput).to.equal('2025/03/10'); 165 120 }); ··· 169 124 input, 170 125 output, 171 126 render: () => 'foo', 172 - formatConfig: YYYY_MM_DD, 127 + format: 'YMD', 173 128 minDate: d('2025-03-10'), 174 129 maxDate: d('2025-11-20'), 175 130 }); 176 131 instance.prompt(); 177 132 input.emit('keypress', undefined, { name: 'down' }); 178 133 expect(instance.userInput).to.equal('2025/__/__'); 179 - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); 134 + input.emit('keypress', undefined, { name: 'right' }); 180 135 input.emit('keypress', undefined, { name: 'down' }); 181 136 expect(instance.userInput).to.equal('2025/11/__'); 182 - for (let i = 0; i < 2; i++) input.emit('keypress', undefined, { name: 'right' }); 137 + input.emit('keypress', undefined, { name: 'right' }); 183 138 input.emit('keypress', undefined, { name: 'down' }); 184 139 expect(instance.userInput).to.equal('2025/11/20'); 185 140 }); ··· 189 144 input, 190 145 output, 191 146 render: () => 'foo', 192 - formatConfig: YYYY_MM_DD, 147 + format: 'YMD', 193 148 initialValue: d('2025-01-15'), 194 149 }); 195 150 instance.prompt(); 196 - expect(instance.segmentCursor).to.deep.equal({ 197 - segmentIndex: 0, 198 - positionInSegment: 0, 199 - }); 200 - // Type 2,0,2,3 to change 2025 -> 2023 (edit digit by digit) 151 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); 152 + // Type 2,0,2,3 to change year to 2023 (right-to-left fill) 201 153 input.emit('keypress', '2', { name: undefined, sequence: '2' }); 154 + expect(instance.userInput).to.equal('___2/01/15'); 202 155 input.emit('keypress', '0', { name: undefined, sequence: '0' }); 156 + expect(instance.userInput).to.equal('__20/01/15'); 203 157 input.emit('keypress', '2', { name: undefined, sequence: '2' }); 158 + expect(instance.userInput).to.equal('_202/01/15'); 204 159 input.emit('keypress', '3', { name: undefined, sequence: '3' }); 205 160 expect(instance.userInput).to.equal('2023/01/15'); 206 - expect(instance.segmentCursor).to.deep.equal({ 207 - segmentIndex: 0, 208 - positionInSegment: 3, 209 - }); 210 161 }); 211 162 212 163 test('backspace clears entire segment at any cursor position', () => { ··· 214 165 input, 215 166 output, 216 167 render: () => 'foo', 217 - formatConfig: YYYY_MM_DD, 168 + format: 'YMD', 218 169 initialValue: d('2025-12-21'), 219 170 }); 220 171 instance.prompt(); 221 172 expect(instance.userInput).to.equal('2025/12/21'); 222 - expect(instance.segmentCursor).to.deep.equal({ 223 - segmentIndex: 0, 224 - positionInSegment: 0, 225 - }); 226 - // Backspace at first position clears whole year segment 227 173 input.emit('keypress', undefined, { name: 'backspace', sequence: '\x7f' }); 228 174 expect(instance.userInput).to.equal('____/12/21'); 229 - expect(instance.segmentCursor).to.deep.equal({ 230 - segmentIndex: 0, 231 - positionInSegment: 0, 232 - }); 175 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); 233 176 }); 234 177 235 178 test('backspace clears segment when cursor at first char (2___)', () => { ··· 237 180 input, 238 181 output, 239 182 render: () => 'foo', 240 - formatConfig: YYYY_MM_DD, 183 + format: 'YMD', 241 184 }); 242 185 instance.prompt(); 243 - // Type "2" to get "2___" 244 186 input.emit('keypress', '2', { name: undefined, sequence: '2' }); 245 - expect(instance.userInput).to.equal('2___/__/__'); 246 - expect(instance.segmentCursor).to.deep.equal({ 247 - segmentIndex: 0, 248 - positionInSegment: 1, 249 - }); 250 - // Move to first char (position 0) 251 - input.emit('keypress', undefined, { name: 'left' }); 252 - expect(instance.segmentCursor).to.deep.equal({ 253 - segmentIndex: 0, 254 - positionInSegment: 0, 255 - }); 256 - // Backspace should clear whole segment - also test char-based detection 187 + expect(instance.userInput).to.equal('___2/__/__'); 257 188 input.emit('keypress', '\x7f', { name: undefined, sequence: '\x7f' }); 258 189 expect(instance.userInput).to.equal('____/__/__'); 259 - expect(instance.segmentCursor).to.deep.equal({ 260 - segmentIndex: 0, 261 - positionInSegment: 0, 262 - }); 190 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); 263 191 }); 264 192 265 193 test('digit input updates segment and jumps to next when complete', () => { ··· 267 195 input, 268 196 output, 269 197 render: () => 'foo', 270 - formatConfig: YYYY_MM_DD, 198 + format: 'YMD', 271 199 }); 272 200 instance.prompt(); 273 - // Type year 2025 - left-to-right, jumps to month when year complete 274 201 for (const c of '2025') { 275 202 input.emit('keypress', c, { name: undefined, sequence: c }); 276 203 } 277 204 expect(instance.userInput).to.equal('2025/__/__'); 278 - expect(instance.segmentCursor).to.deep.equal({ 279 - segmentIndex: 1, 280 - positionInSegment: 0, 281 - }); 205 + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); 282 206 }); 283 207 284 - test('submit returns ISO string for valid date', async () => { 208 + test('submit returns Date for valid date', async () => { 285 209 const instance = new DatePrompt({ 286 210 input, 287 211 output, 288 212 render: () => 'foo', 289 - formatConfig: YYYY_MM_DD, 213 + format: 'YMD', 290 214 initialValue: d('2025-01-31'), 291 215 }); 292 216 const resultPromise = instance.prompt(); ··· 301 225 input, 302 226 output, 303 227 render: () => 'foo', 304 - formatConfig: YYYY_MM_DD, 228 + format: 'YMD', 305 229 initialValue: d('2025-01-15'), 306 230 }); 307 231 const resultPromise = instance.prompt(); ··· 315 239 input, 316 240 output, 317 241 render: () => 'foo', 318 - formatConfig: YYYY_MM_DD, 242 + format: 'YMD', 319 243 defaultValue: d('2025-06-15'), 320 244 }); 321 245 const resultPromise = instance.prompt(); ··· 325 249 expect((result as Date).toISOString().slice(0, 10)).to.equal('2025-06-15'); 326 250 }); 327 251 328 - test('supports MM/DD/YYYY format', () => { 252 + test('supports MDY format', () => { 329 253 const instance = new DatePrompt({ 330 254 input, 331 255 output, 332 256 render: () => 'foo', 333 - formatConfig: MM_DD_YYYY, 257 + format: 'MDY', 334 258 initialValue: d('2025-01-15'), 335 259 }); 336 260 instance.prompt(); 337 261 expect(instance.userInput).to.equal('01/15/2025'); 338 262 }); 339 263 340 - test('rejects invalid month and shows inline error', () => { 264 + test('supports DMY format', () => { 341 265 const instance = new DatePrompt({ 342 266 input, 343 267 output, 344 268 render: () => 'foo', 345 - formatConfig: YYYY_MM_DD, 346 - initialValue: d('2025-01-15'), // month is 01 269 + format: 'DMY', 270 + initialValue: d('2025-01-15'), 347 271 }); 348 272 instance.prompt(); 349 - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month (cursor at start) 350 - input.emit('keypress', '3', { name: undefined, sequence: '3' }); // 0→3 gives 31, invalid 351 - expect(instance.userInput).to.equal('2025/01/15'); // stayed - 31 rejected 352 - expect(instance.inlineError).to.equal('There are only 12 months in a year'); 273 + expect(instance.userInput).to.equal('15/01/2025'); 353 274 }); 354 275 355 - test('rejects invalid day and shows inline error', () => { 276 + test('rejects invalid month via pending tens digit', () => { 356 277 const instance = new DatePrompt({ 357 278 input, 358 279 output, 359 280 render: () => 'foo', 360 - formatConfig: YYYY_MM_DD, 361 - initialValue: d('2025-01-15'), // January has 31 days 281 + format: 'YMD', 362 282 }); 363 283 instance.prompt(); 364 - for (let i = 0; i < 6; i++) input.emit('keypress', undefined, { name: 'right' }); // move to day (cursor at start) 365 - input.emit('keypress', '4', { name: undefined, sequence: '4' }); // 1→4 gives 45, invalid for Jan 366 - expect(instance.userInput).to.equal('2025/01/15'); // stayed - 45 rejected 367 - expect(instance.inlineError).to.contain('31 days'); 368 - expect(instance.inlineError).to.contain('January'); 284 + // Navigate to month 285 + input.emit('keypress', undefined, { name: 'right' }); 286 + // Type '1' → '01' with pending tens digit (since 1 <= 1) 287 + input.emit('keypress', '1', { name: undefined, sequence: '1' }); 288 + expect(instance.segmentValues.month).to.equal('01'); 289 + // Type '3' → tries '13' which is > 12 → inline error 290 + input.emit('keypress', '3', { name: undefined, sequence: '3' }); 291 + expect(instance.inlineError).to.equal('There are only 12 months in a year'); 369 292 }); 370 293 371 - test('supports DD/MM/YYYY format', () => { 294 + test('rejects invalid day via pending tens digit', () => { 372 295 const instance = new DatePrompt({ 373 296 input, 374 297 output, 375 298 render: () => 'foo', 376 - formatConfig: DD_MM_YYYY, 377 - initialValue: d('2025-01-15'), 299 + format: 'YMD', 378 300 }); 379 301 instance.prompt(); 380 - expect(instance.userInput).to.equal('15/01/2025'); 302 + // Navigate to day 303 + input.emit('keypress', undefined, { name: 'right' }); 304 + input.emit('keypress', undefined, { name: 'right' }); 305 + // Type '2' → '02' with pending (2 <= 2) 306 + input.emit('keypress', '2', { name: undefined, sequence: '2' }); 307 + input.emit('keypress', '0', { name: undefined, sequence: '0' }); 308 + expect(instance.inlineError).to.equal(''); 381 309 }); 382 310 383 311 describe('segmentValues and segmentCursor', () => { ··· 386 314 input, 387 315 output, 388 316 render: () => 'foo', 389 - formatConfig: YYYY_MM_DD, 317 + format: 'YMD', 390 318 initialValue: d('2025-01-15'), 391 319 }); 392 320 instance.prompt(); ··· 401 329 input, 402 330 output, 403 331 render: () => 'foo', 404 - formatConfig: YYYY_MM_DD, 332 + format: 'YMD', 405 333 initialValue: d('2025-01-15'), 406 334 }); 407 335 instance.prompt(); 408 - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month 336 + input.emit('keypress', undefined, { name: 'right' }); // move to month 409 337 const cursor = instance.segmentCursor; 410 - expect(cursor.segmentIndex).to.equal(1); // month segment 411 - expect(cursor.positionInSegment).to.equal(0); // start of segment 338 + expect(cursor.segmentIndex).to.equal(1); 339 + expect(cursor.positionInSegment).to.equal(0); 412 340 }); 413 341 414 342 test('segmentValues updates on submit', () => { ··· 416 344 input, 417 345 output, 418 346 render: () => 'foo', 419 - formatConfig: YYYY_MM_DD, 347 + format: 'YMD', 420 348 initialValue: d('2025-01-15'), 421 349 }); 422 350 instance.prompt(); ··· 425 353 expect(segmentValues.year).to.equal('2025'); 426 354 expect(segmentValues.month).to.equal('01'); 427 355 expect(segmentValues.day).to.equal('15'); 356 + }); 357 + }); 358 + 359 + describe('formattedValue and segments', () => { 360 + test('formattedValue returns formatted string', () => { 361 + const instance = new DatePrompt({ 362 + input, 363 + output, 364 + render: () => 'foo', 365 + format: 'MDY', 366 + initialValue: d('2025-03-15'), 367 + }); 368 + instance.prompt(); 369 + expect(instance.formattedValue).to.equal('03/15/2025'); 370 + }); 371 + 372 + test('segments exposes segment config', () => { 373 + const instance = new DatePrompt({ 374 + input, 375 + output, 376 + render: () => 'foo', 377 + format: 'DMY', 378 + }); 379 + instance.prompt(); 380 + expect(instance.segments).to.deep.equal([ 381 + { type: 'day', len: 2 }, 382 + { type: 'month', len: 2 }, 383 + { type: 'year', len: 4 }, 384 + ]); 385 + }); 386 + 387 + test('separator defaults to / for explicit format', () => { 388 + const instance = new DatePrompt({ 389 + input, 390 + output, 391 + render: () => 'foo', 392 + format: 'YMD', 393 + }); 394 + instance.prompt(); 395 + expect(instance.separator).to.equal('/'); 396 + }); 397 + }); 398 + 399 + describe('locale detection', () => { 400 + test('locale auto-detects format from Intl', () => { 401 + const instance = new DatePrompt({ 402 + input, 403 + output, 404 + render: () => 'foo', 405 + locale: 'en-US', 406 + initialValue: d('2025-03-15'), 407 + }); 408 + instance.prompt(); 409 + // en-US is MDY 410 + expect(instance.segments[0].type).to.equal('month'); 411 + expect(instance.segments[1].type).to.equal('day'); 412 + expect(instance.segments[2].type).to.equal('year'); 413 + }); 414 + 415 + test('explicit format overrides locale', () => { 416 + const instance = new DatePrompt({ 417 + input, 418 + output, 419 + render: () => 'foo', 420 + format: 'YMD', 421 + locale: 'en-US', // would be MDY, but format takes precedence 422 + initialValue: d('2025-03-15'), 423 + }); 424 + instance.prompt(); 425 + expect(instance.segments[0].type).to.equal('year'); 428 426 }); 429 427 }); 430 428 });
+66 -151
packages/prompts/src/date.ts
··· 1 1 import { styleText } from 'node:util'; 2 - import type { DateFormatConfig, DateParts } from '@clack/core'; 2 + import type { DateFormat, State } from '@clack/core'; 3 3 import { DatePrompt, settings } from '@clack/core'; 4 4 import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; 5 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>; 6 + export type { DateFormat }; 109 7 110 8 export interface DateOptions extends CommonOptions { 111 9 message: string; 112 10 format?: DateFormat; 11 + locale?: string; 113 12 defaultValue?: Date; 114 13 initialValue?: Date; 115 14 minDate?: Date; ··· 119 18 120 19 export const date = (opts: DateOptions) => { 121 20 const validate = opts.validate; 122 - const formatConfig = FORMAT_CONFIGS[opts.format ?? 'YYYY/MM/DD']; 123 21 return new DatePrompt({ 124 - formatConfig, 125 - defaultValue: opts.defaultValue, 126 - initialValue: opts.initialValue, 127 - minDate: opts.minDate, 128 - maxDate: opts.maxDate, 22 + ...opts, 129 23 validate(value: Date | undefined) { 130 24 if (value === undefined) { 131 25 if (opts.defaultValue !== undefined) return undefined; 132 26 if (validate) return validate(value); 133 - return 'Please enter a valid date'; 27 + return settings.date.messages.required; 134 28 } 135 - const dateOnly = (d: Date) => d.toISOString().slice(0, 10); 136 - if (opts.minDate && dateOnly(value) < dateOnly(opts.minDate)) { 29 + const iso = (d: Date) => d.toISOString().slice(0, 10); 30 + if (opts.minDate && iso(value) < iso(opts.minDate)) { 137 31 return settings.date.messages.afterMin(opts.minDate); 138 32 } 139 - if (opts.maxDate && dateOnly(value) > dateOnly(opts.maxDate)) { 33 + if (opts.maxDate && iso(value) > iso(opts.maxDate)) { 140 34 return settings.date.messages.beforeMax(opts.maxDate); 141 35 } 142 36 if (validate) return validate(value); 143 37 return undefined; 144 38 }, 145 - signal: opts.signal, 146 - input: opts.input, 147 - output: opts.output, 148 39 render() { 149 40 const hasGuide = (opts?.withGuide ?? settings.withGuide) !== false; 150 41 const titlePrefix = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} `; 151 42 const title = `${titlePrefix}${opts.message}\n`; 152 43 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); 44 + const state = this.state !== 'initial' ? this.state : 'active'; 166 45 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 - : ''; 46 + const userInput = renderDate(this, state); 47 + const value = this.value instanceof Date ? this.formattedValue : ''; 175 48 176 49 switch (this.state) { 177 50 case 'error': { 178 51 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`; 52 + const bar = hasGuide ? `${styleText('yellow', S_BAR)} ` : ''; 53 + const barEnd = hasGuide ? styleText('yellow', S_BAR_END) : ''; 54 + return `${title.trim()}\n${bar}${userInput}\n${barEnd}${errorText}\n`; 182 55 } 183 56 case 'submit': { 184 57 const valueText = value ? ` ${styleText('dim', value)}` : ''; 185 - const submitPrefix = hasGuide ? styleText('gray', S_BAR) : ''; 186 - return `${title}${submitPrefix}${valueText}`; 58 + const bar = hasGuide ? styleText('gray', S_BAR) : ''; 59 + return `${title}${bar}${valueText}`; 187 60 } 188 61 case 'cancel': { 189 62 const valueText = value ? ` ${styleText(['strikethrough', 'dim'], value)}` : ''; 190 - const cancelPrefix = hasGuide ? styleText('gray', S_BAR) : ''; 191 - return `${title}${cancelPrefix}${valueText}${value.trim() ? `\n${cancelPrefix}` : ''}`; 63 + const bar = hasGuide ? styleText('gray', S_BAR) : ''; 64 + return `${title}${bar}${valueText}${value.trim() ? `\n${bar}` : ''}`; 192 65 } 193 66 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)}` 67 + const bar = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''; 68 + const barEnd = hasGuide ? styleText('cyan', S_BAR_END) : ''; 69 + const inlineBar = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''; 70 + const inlineError = this.inlineError 71 + ? `\n${inlineBar}${styleText('yellow', this.inlineError)}` 199 72 : ''; 200 - return `${title}${defaultPrefix}${userInput}${inlineError}\n${defaultPrefixEnd}\n`; 73 + return `${title}${bar}${userInput}${inlineError}\n${barEnd}\n`; 201 74 } 202 75 } 203 76 }, 204 77 }).prompt() as Promise<Date | symbol>; 205 78 }; 79 + 80 + 81 + function renderDate( 82 + prompt: Omit<InstanceType<typeof DatePrompt>, 'prompt'>, 83 + state: State 84 + ): string { 85 + const parts = prompt.segmentValues; 86 + const cursor = prompt.segmentCursor; 87 + 88 + if (state === 'submit' || state === 'cancel') { 89 + return prompt.formattedValue; 90 + } 91 + 92 + const sep = styleText('gray', prompt.separator); 93 + return prompt.segments 94 + .map((seg, i) => { 95 + const isActive = i === cursor.segmentIndex && !['submit', 'cancel'].includes(state); 96 + const label = DEFAULT_LABELS[seg.type]; 97 + return renderSegment(parts[seg.type], { isActive, label }); 98 + }) 99 + .join(sep); 100 + } 101 + 102 + interface SegmentOptions { 103 + isActive: boolean; 104 + label: string; 105 + } 106 + function renderSegment( 107 + value: string, 108 + opts: SegmentOptions, 109 + ): string { 110 + const isBlank = !value || value.replace(/_/g, '') === ''; 111 + if (opts.isActive) return styleText('inverse', isBlank ? opts.label : value.replace(/_/g, ' ')); 112 + if (isBlank) return styleText('dim', opts.label); 113 + return value.replace(/_/g, styleText('dim', ' ')); 114 + } 115 + 116 + const DEFAULT_LABELS: Record<'year' | 'month' | 'day', string> = { 117 + year: 'yyyy', 118 + month: 'mm', 119 + day: 'dd', 120 + };
+26 -26
packages/prompts/test/__snapshots__/date.test.ts.snap
··· 5 5 "<cursor.hide>", 6 6 "│ 7 7 ◆ Pick a date 8 - │  yyy/mm/dd 8 + │ mm/dd/yyyy 9 9 └ 10 10 ", 11 11 "<cursor.backward count=999><cursor.up count=4>", ··· 24 24 "<cursor.hide>", 25 25 "│ 26 26 ◆ Pick a date 27 - │ 2025/12/25 27 + │ 12/25/2025 28 28 └ 29 29 ", 30 30 "<cursor.backward count=999><cursor.up count=4>", 31 31 "<cursor.down count=1>", 32 32 "<erase.down>", 33 33 "◇ Pick a date 34 - │ 2025/12/25", 34 + │ 12/25/2025", 35 35 " 36 36 ", 37 37 "<cursor.show>", ··· 43 43 "<cursor.hide>", 44 44 "│ 45 45 ◆ Pick a date 46 - │ 2025/01/15 46 + │ 01/15/2025 47 47 └ 48 48 ", 49 49 "<cursor.backward count=999><cursor.up count=4>", 50 50 "<cursor.down count=1>", 51 51 "<erase.down>", 52 52 "◇ Pick a date 53 - │ 2025/01/15", 53 + │ 01/15/2025", 54 54 " 55 55 ", 56 56 "<cursor.show>", ··· 62 62 "<cursor.hide>", 63 63 "│ 64 64 ◆ Pick a date 65 - │ 2025/01/15 65 + │ 01/15/2025 66 66 └ 67 67 ", 68 68 "<cursor.backward count=999><cursor.up count=4>", 69 69 "<cursor.down count=1>", 70 70 "<erase.down>", 71 71 "◇ Pick a date 72 - │ 2025/01/15", 72 + │ 01/15/2025", 73 73 " 74 74 ", 75 75 "<cursor.show>", ··· 81 81 "<cursor.hide>", 82 82 "│ 83 83 ◆ Pick a date 84 - │ 2025/06/15 84 + │ 06/15/2025 85 85 └ 86 86 ", 87 87 "<cursor.backward count=999><cursor.up count=4>", 88 88 "<cursor.down count=1>", 89 89 "<erase.down>", 90 90 "◇ Pick a date 91 - │ 2025/06/15", 91 + │ 06/15/2025", 92 92 " 93 93 ", 94 94 "<cursor.show>", 95 95 ] 96 96 `; 97 97 98 - exports[`date (isCI = false) > supports MM/DD/YYYY format 1`] = ` 98 + exports[`date (isCI = false) > supports MDY format 1`] = ` 99 99 [ 100 100 "<cursor.hide>", 101 101 "│ 102 102 ◆ Pick a date 103 - │ 01/15/2025 103 + │ 01/15/2025 104 104 └ 105 105 ", 106 106 "<cursor.backward count=999><cursor.up count=4>", ··· 118 118 [ 119 119 "<cursor.hide>", 120 120 "◆ Pick a date 121 - 2025/01/15 121 + 01/15/2025 122 122 123 123 ", 124 124 "<cursor.backward count=999><cursor.up count=3>", 125 125 "<erase.down>", 126 126 "◇ Pick a date 127 - 2025/01/15", 127 + 01/15/2025", 128 128 " 129 129 ", 130 130 "<cursor.show>", ··· 136 136 "<cursor.hide>", 137 137 "│ 138 138 ◆ Pick a date 139 - │  yyy/mm/dd 139 + │ mm/dd/yyyy 140 140 └ 141 141 ", 142 142 "<cursor.backward count=999><cursor.up count=4>", ··· 155 155 "<cursor.hide>", 156 156 "│ 157 157 ◆ Pick a date 158 - │ 2025/12/25 158 + │ 12/25/2025 159 159 └ 160 160 ", 161 161 "<cursor.backward count=999><cursor.up count=4>", 162 162 "<cursor.down count=1>", 163 163 "<erase.down>", 164 164 "◇ Pick a date 165 - │ 2025/12/25", 165 + │ 12/25/2025", 166 166 " 167 167 ", 168 168 "<cursor.show>", ··· 174 174 "<cursor.hide>", 175 175 "│ 176 176 ◆ Pick a date 177 - │ 2025/01/15 177 + │ 01/15/2025 178 178 └ 179 179 ", 180 180 "<cursor.backward count=999><cursor.up count=4>", 181 181 "<cursor.down count=1>", 182 182 "<erase.down>", 183 183 "◇ Pick a date 184 - │ 2025/01/15", 184 + │ 01/15/2025", 185 185 " 186 186 ", 187 187 "<cursor.show>", ··· 193 193 "<cursor.hide>", 194 194 "│ 195 195 ◆ Pick a date 196 - │ 2025/01/15 196 + │ 01/15/2025 197 197 └ 198 198 ", 199 199 "<cursor.backward count=999><cursor.up count=4>", 200 200 "<cursor.down count=1>", 201 201 "<erase.down>", 202 202 "◇ Pick a date 203 - │ 2025/01/15", 203 + │ 01/15/2025", 204 204 " 205 205 ", 206 206 "<cursor.show>", ··· 212 212 "<cursor.hide>", 213 213 "│ 214 214 ◆ Pick a date 215 - │ 2025/06/15 215 + │ 06/15/2025 216 216 └ 217 217 ", 218 218 "<cursor.backward count=999><cursor.up count=4>", 219 219 "<cursor.down count=1>", 220 220 "<erase.down>", 221 221 "◇ Pick a date 222 - │ 2025/06/15", 222 + │ 06/15/2025", 223 223 " 224 224 ", 225 225 "<cursor.show>", 226 226 ] 227 227 `; 228 228 229 - exports[`date (isCI = true) > supports MM/DD/YYYY format 1`] = ` 229 + exports[`date (isCI = true) > supports MDY format 1`] = ` 230 230 [ 231 231 "<cursor.hide>", 232 232 "│ 233 233 ◆ Pick a date 234 - │ 01/15/2025 234 + │ 01/15/2025 235 235 └ 236 236 ", 237 237 "<cursor.backward count=999><cursor.up count=4>", ··· 249 249 [ 250 250 "<cursor.hide>", 251 251 "◆ Pick a date 252 - 2025/01/15 252 + 01/15/2025 253 253 254 254 ", 255 255 "<cursor.backward count=999><cursor.up count=3>", 256 256 "<erase.down>", 257 257 "◇ Pick a date 258 - 2025/01/15", 258 + 01/15/2025", 259 259 " 260 260 ", 261 261 "<cursor.show>",
+2 -2
packages/prompts/test/date.test.ts
··· 129 129 expect(output.buffer).toMatchSnapshot(); 130 130 }); 131 131 132 - test('supports MM/DD/YYYY format', async () => { 132 + test('supports MDY format', async () => { 133 133 const result = prompts.date({ 134 134 message: 'Pick a date', 135 - format: 'MM/DD/YYYY', 135 + format: 'MDY', 136 136 initialValue: d('2025-01-15'), 137 137 input, 138 138 output,