diff --git a/.changeset/early-ads-tap.md b/.changeset/early-ads-tap.md deleted file mode 100644 index 4744ce64..00000000 --- a/.changeset/early-ads-tap.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@clack/prompts": patch -"@clack/core": patch ---- - -Fix timezone issues in DatePrompt causing dates to be off by one day in non-UTC timezones diff --git a/.changeset/tangy-mirrors-hug.md b/.changeset/tangy-mirrors-hug.md index 61e67153..a66eee6c 100644 --- a/.changeset/tangy-mirrors-hug.md +++ b/.changeset/tangy-mirrors-hug.md @@ -3,4 +3,4 @@ "@clack/core": minor --- -Adds `date` prompt with format support (YYYY/MM/DD, MM/DD/YYYY, DD/MM/YYYY). +Adds `date` prompt with `format` support (YMD, MDY, DMY) diff --git a/examples/basic/date.ts b/examples/basic/date.ts index ece41f1b..65daaf92 100644 --- a/examples/basic/date.ts +++ b/examples/basic/date.ts @@ -4,9 +4,9 @@ import color from 'picocolors'; async function main() { const result = (await p.date({ message: color.magenta('Pick a date'), - format: 'YYYY/MM/DD', - minDate: new Date('2025-01-01'), - maxDate: new Date('2025-12-31'), + format: 'YMD', + minDate: new Date('2026-01-01'), + maxDate: new Date('2026-12-31'), })) as Date; if (p.isCancel(result)) { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index edd222f3..6a83a200 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -2,7 +2,7 @@ export type { AutocompleteOptions } from './prompts/autocomplete.js'; export { default as AutocompletePrompt } from './prompts/autocomplete.js'; export type { ConfirmOptions } from './prompts/confirm.js'; export { default as ConfirmPrompt } from './prompts/confirm.js'; -export type { DateFormatConfig, DateOptions, DateParts } from './prompts/date.js'; +export type { DateFormat, DateOptions, DateParts } from './prompts/date.js'; export { default as DatePrompt } from './prompts/date.js'; export type { GroupMultiSelectOptions } from './prompts/group-multiselect.js'; export { default as GroupMultiSelectPrompt } from './prompts/group-multiselect.js'; diff --git a/packages/core/src/prompts/date.ts b/packages/core/src/prompts/date.ts index 702e00ce..62cee0c5 100644 --- a/packages/core/src/prompts/date.ts +++ b/packages/core/src/prompts/date.ts @@ -13,158 +13,97 @@ export interface DateParts { day: string; } -/** Format config passed from prompts package; core does not define presets */ -export interface DateFormatConfig { - segments: SegmentConfig[]; - format: (parts: DateParts) => string; - /** Labels shown when segment is blank (e.g. yyyy, mm, dd). Default: { year: 'yyyy', month: 'mm', day: 'dd' } */ - segmentLabels?: { year: string; month: string; day: string }; -} - -function clamp(min: number, value: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} - -/** Convert Date directly to segment values - no string parsing needed */ -function dateToSegmentValues(date: Date | undefined): DateParts { - if (!date) { - return { year: '____', month: '__', day: '__' }; - } - return { - year: String(date.getUTCFullYear()).padStart(4, '0'), - month: String(date.getUTCMonth() + 1).padStart(2, '0'), - day: String(date.getUTCDate()).padStart(2, '0'), - }; -} +export type DateFormat = 'YMD' | 'MDY' | 'DMY'; -function segmentValuesToParsed(parts: DateParts): { - year: number; - month: number; - day: number; -} { - const val = (s: string) => Number.parseInt((s || '0').replace(/_/g, '0'), 10) || 0; - return { - year: val(parts.year), - month: val(parts.month), - day: val(parts.day), - }; -} +const SEGMENTS: Record = { Y: { type: 'year', len: 4 }, M: { type: 'month', len: 2 }, D: { type: 'day', len: 2 } } as const; -function daysInMonth(year: number, month: number): number { - return new Date(year || 2001, month || 1, 0).getDate(); +function segmentsFor(fmt: DateFormat): SegmentConfig[] { + return [...fmt].map((c) => SEGMENTS[c as keyof typeof SEGMENTS]); } -function clampSegment( - value: number, - type: 'year' | 'month' | 'day', - context: { year: number; month: number } -): number { - if (type === 'year') { - return clamp(1000, value || 1000, 9999); - } - if (type === 'month') { - return clamp(1, value || 1, 12); +function detectLocaleFormat(locale?: string): { segments: SegmentConfig[]; separator: string } { + const fmt = new Intl.DateTimeFormat(locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }); + const parts = fmt.formatToParts(new Date(2000, 0, 15)); + const segments: SegmentConfig[] = []; + let separator = '/'; + for (const p of parts) { + if (p.type === 'literal') { + separator = p.value.trim() || p.value; + } else if (p.type === 'year' || p.type === 'month' || p.type === 'day') { + segments.push({ type: p.type, len: p.type === 'year' ? 4 : 2 }); + } } - const { year, month } = context; - return clamp(1, value || 1, daysInMonth(year, month)); + return { segments, separator }; } -function datePartsUTC(d: Date): { year: number; month: number; day: number } { - return { - year: d.getUTCFullYear(), - month: d.getUTCMonth() + 1, - day: d.getUTCDate(), - }; +/** Parse string segment values to numbers, treating blanks as 0 */ +function parseSegmentToNum(s: string): number { + return Number.parseInt((s || '0').replace(/_/g, '0'), 10) || 0; } -function getSegmentBounds( - type: 'year' | 'month' | 'day', - context: { year: number; month: number; day: number }, - minDate: Date | undefined, - maxDate: Date | undefined -): { min: number; max: number } { - const { year, month } = context; - const minParts = minDate ? datePartsUTC(minDate) : null; - const maxParts = maxDate ? datePartsUTC(maxDate) : null; +function parse(parts: DateParts): { year: number; month: number; day: number } { + return { year: parseSegmentToNum(parts.year), month: parseSegmentToNum(parts.month), day: parseSegmentToNum(parts.day) }; +} - if (type === 'year') { - const min = minParts ? minParts.year : 1000; - const max = maxParts ? maxParts.year : 9999; - return { min, max }; - } - if (type === 'month') { - let min = 1; - let max = 12; - if (minParts && year && year >= minParts.year) { - if (year === minParts.year) min = minParts.month; - } - if (maxParts && year && year <= maxParts.year) { - if (year === maxParts.year) max = maxParts.month; - } - return { min, max }; - } - // day - let min = 1; - let max = daysInMonth(year, month); - if (minParts && year && month && year === minParts.year && month === minParts.month) { - min = minParts.day; - } - if (maxParts && year && month && year === maxParts.year && month === maxParts.month) { - max = maxParts.day; - } - return { min, max }; +function daysInMonth(year: number, month: number): number { + return new Date(year || 2001, month || 1, 0).getDate(); } -/** Parse segment values to calendar parts; returns undefined if invalid. */ -function segmentValuesToParts( +/** Validate and return calendar parts, or undefined if invalid */ +function validParts( parts: DateParts ): { year: number; month: number; day: number } | undefined { - const { year, month, day } = segmentValuesToParsed(parts); - if (!year || year < 1000 || year > 9999) return undefined; + const { year, month, day } = parse(parts); + if (!year || year < 0 || year > 9999) return undefined; if (!month || month < 1 || month > 12) return undefined; if (!day || day < 1) return undefined; - const date = new Date(Date.UTC(year, month - 1, day)); - if ( - date.getUTCFullYear() !== year || - date.getUTCMonth() !== month - 1 || - date.getUTCDate() !== day - ) { + const d = new Date(Date.UTC(year, month - 1, day)); + if (d.getUTCFullYear() !== year || d.getUTCMonth() !== month - 1 || d.getUTCDate() !== day) return undefined; - } return { year, month, day }; } -/** Build a Date from segment values using UTC midnight so getFullYear/getMonth/getDate are timezone-stable. */ -function segmentValuesToDate(parts: DateParts): Date | undefined { - const parsed = segmentValuesToParts(parts); - if (!parsed) return undefined; - return new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day)); +function toDate(parts: DateParts): Date | undefined { + const p = validParts(parts); + return p ? new Date(Date.UTC(p.year, p.month - 1, p.day)) : undefined; } -function segmentValuesToISOString(parts: DateParts): string | undefined { - const parsed = segmentValuesToParts(parts); - if (!parsed) return undefined; - return new Date(Date.UTC(parsed.year, parsed.month - 1, parsed.day)).toISOString().slice(0, 10); -} +function segmentBounds( + type: 'year' | 'month' | 'day', + ctx: { year: number; month: number }, + minDate: Date | undefined, + maxDate: Date | undefined +): { min: number; max: number } { + const minP = minDate + ? { year: minDate.getUTCFullYear(), month: minDate.getUTCMonth() + 1, day: minDate.getUTCDate() } + : null; + const maxP = maxDate + ? { year: maxDate.getUTCFullYear(), month: maxDate.getUTCMonth() + 1, day: maxDate.getUTCDate() } + : null; -function getSegmentValidationMessage(parts: DateParts, seg: SegmentConfig): string | undefined { - const { year, month, day } = segmentValuesToParsed(parts); - if (seg.type === 'month' && (month < 1 || month > 12)) { - return settings.date.messages.invalidMonth; + if (type === 'year') { + return { min: minP?.year ?? 1, max: maxP?.year ?? 9999 }; } - if (seg.type === 'day') { - if (day < 1) return undefined; - if (day > daysInMonth(year, month)) { - const monthName = - month >= 1 && month <= 12 ? settings.date.monthNames[month - 1] : 'this month'; - return settings.date.messages.invalidDay(daysInMonth(year, month), monthName); - } + if (type === 'month') { + return { + min: minP && ctx.year === minP.year ? minP.month : 1, + max: maxP && ctx.year === maxP.year ? maxP.month : 12, + }; } - return undefined; + return { + min: minP && ctx.year === minP.year && ctx.month === minP.month ? minP.day : 1, + max: maxP && ctx.year === maxP.year && ctx.month === maxP.month ? maxP.day : daysInMonth(ctx.year, ctx.month), + }; } export interface DateOptions extends PromptOptions { - formatConfig: DateFormatConfig; + format?: DateFormat; + locale?: string; + separator?: string; defaultValue?: Date; initialValue?: Date; minDate?: Date; @@ -172,21 +111,17 @@ export interface DateOptions extends PromptOptions { } export default class DatePrompt extends Prompt { - #config: DateFormatConfig; + #segments: SegmentConfig[]; + #separator: string; #segmentValues: DateParts; #minDate: Date | undefined; #maxDate: Date | undefined; #cursor = { segmentIndex: 0, positionInSegment: 0 }; + #segmentSelected = true; + #pendingTensDigit: string | null = null; - /** Inline validation message shown beneath input during editing */ inlineError = ''; - #refreshFromSegmentValues() { - const display = this.#config.format(this.#segmentValues); - this._setUserInput(display); - this._setValue(segmentValuesToDate(this.#segmentValues) ?? undefined); - } - get segmentCursor() { return { ...this.#cursor }; } @@ -195,192 +130,263 @@ export default class DatePrompt extends Prompt { return { ...this.#segmentValues }; } + get segments(): readonly SegmentConfig[] { + return this.#segments; + } + + get separator(): string { + return this.#separator; + } + + get formattedValue(): string { + return this.#format(this.#segmentValues); + } + + #format(parts: DateParts): string { + return this.#segments.map((s) => parts[s.type]).join(this.#separator); + } + + #refresh() { + this._setUserInput(this.#format(this.#segmentValues)); + this._setValue(toDate(this.#segmentValues) ?? undefined); + } + constructor(opts: DateOptions) { - const config = opts.formatConfig; + const detected = opts.format + ? { segments: segmentsFor(opts.format), separator: opts.separator ?? '/' } + : detectLocaleFormat(opts.locale); + const sep = opts.separator ?? detected.separator; + const segments = opts.format ? segmentsFor(opts.format) : detected.segments; + const initialDate = opts.initialValue ?? opts.defaultValue; - const segmentValues = dateToSegmentValues(initialDate); - const initialDisplay = config.format(segmentValues); - - super( - { - ...opts, - initialUserInput: initialDisplay, - }, - false - ); - this.#config = config; + const segmentValues: DateParts = initialDate + ? { + year: String(initialDate.getUTCFullYear()).padStart(4, '0'), + month: String(initialDate.getUTCMonth() + 1).padStart(2, '0'), + day: String(initialDate.getUTCDate()).padStart(2, '0'), + } + : { year: '____', month: '__', day: '__' }; + + const initialDisplay = segments.map((s) => segmentValues[s.type]).join(sep); + + super({ ...opts, initialUserInput: initialDisplay }, false); + this.#segments = segments; + this.#separator = sep; this.#segmentValues = segmentValues; this.#minDate = opts.minDate; this.#maxDate = opts.maxDate; - this.#refreshFromSegmentValues(); + this.#refresh(); this.on('cursor', (key) => this.#onCursor(key)); this.on('key', (char, key) => this.#onKey(char, key)); this.on('finalize', () => this.#onFinalize(opts)); } - #getCurrentSegment(): { segment: SegmentConfig; index: number } | undefined { - const index = clamp(0, this.#cursor.segmentIndex, this.#config.segments.length - 1); - const segment = this.#config.segments[index]; + #seg(): { segment: SegmentConfig; index: number } | undefined { + const index = Math.max(0, Math.min(this.#cursor.segmentIndex, this.#segments.length - 1)); + const segment = this.#segments[index]; if (!segment) return undefined; - this.#cursor.positionInSegment = clamp(0, this.#cursor.positionInSegment, segment.len - 1); + this.#cursor.positionInSegment = Math.max(0, Math.min(this.#cursor.positionInSegment, segment.len - 1)); return { segment, index }; } - #moveCursorNext() { + #navigate(direction: 1 | -1) { this.inlineError = ''; - const ctx = this.#getCurrentSegment(); + this.#pendingTensDigit = null; + const ctx = this.#seg(); if (!ctx) return; - const newPos = this.#cursor.positionInSegment + 1; - if (newPos < ctx.segment.len) { - this.#cursor.positionInSegment = newPos; - return; - } - const newIndex = Math.min(this.#config.segments.length - 1, ctx.index + 1); - this.#cursor.segmentIndex = newIndex; + this.#cursor.segmentIndex = Math.max(0, Math.min(this.#segments.length - 1, ctx.index + direction)); this.#cursor.positionInSegment = 0; + this.#segmentSelected = true; } - #moveCursorPrevious() { - this.inlineError = ''; - const ctx = this.#getCurrentSegment(); + #adjust(direction: 1 | -1) { + const ctx = this.#seg(); if (!ctx) return; - const newPos = this.#cursor.positionInSegment - 1; - if (newPos >= 0) { - this.#cursor.positionInSegment = newPos; - return; - } - const newIndex = Math.max(0, ctx.index - 1); - this.#cursor.segmentIndex = newIndex; - this.#cursor.positionInSegment = 0; - } - - #incrementSegment() { - const ctx = this.#getCurrentSegment(); - if (!ctx) return; - this.#adjustSegment(ctx, 1); - } - - #decrementSegment() { - const ctx = this.#getCurrentSegment(); - if (!ctx) return; - this.#adjustSegment(ctx, -1); - } - - #adjustSegment(ctx: { segment: SegmentConfig; index: number }, direction: 1 | -1) { const { segment } = ctx; const raw = this.#segmentValues[segment.type]; const isBlank = !raw || raw.replace(/_/g, '') === ''; const num = Number.parseInt((raw || '0').replace(/_/g, '0'), 10) || 0; - const bounds = getSegmentBounds( - segment.type, - segmentValuesToParsed(this.#segmentValues), - this.#minDate, - this.#maxDate - ); - - const newNum = isBlank - ? direction === 1 - ? bounds.min - : bounds.max - : clamp(bounds.min, num + direction, bounds.max); - - const newSegmentValue = String(newNum).padStart(segment.len, '0'); - this.#segmentValues = { - ...this.#segmentValues, - [segment.type]: newSegmentValue, - }; - this.#refreshFromSegmentValues(); + const bounds = segmentBounds(segment.type, parse(this.#segmentValues), this.#minDate, this.#maxDate); + + let next: number; + if (isBlank) { + next = direction === 1 ? bounds.min : bounds.max; + } else { + next = Math.max(Math.min(bounds.max, num + direction), bounds.min); + } + + this.#segmentValues = { ...this.#segmentValues, [segment.type]: next.toString().padStart(segment.len, '0') }; + this.#segmentSelected = true; + this.#pendingTensDigit = null; + this.#refresh(); } #onCursor(key?: string) { if (!key) return; switch (key) { - case 'right': - return this.#moveCursorNext(); - case 'left': - return this.#moveCursorPrevious(); - case 'up': - return this.#incrementSegment(); - case 'down': - return this.#decrementSegment(); + case 'right': return this.#navigate(1); + case 'left': return this.#navigate(-1); + case 'up': return this.#adjust(1); + case 'down': return this.#adjust(-1); } } #onKey(char: string | undefined, key: Key) { + // Backspace const isBackspace = - key?.name === 'backspace' || - key?.sequence === '\x7f' || - key?.sequence === '\b' || - char === '\x7f' || - char === '\b'; + key?.name === 'backspace' || key?.sequence === '\x7f' || key?.sequence === '\b' || + char === '\x7f' || char === '\b'; if (isBackspace) { this.inlineError = ''; - const ctx = this.#getCurrentSegment(); + const ctx = this.#seg(); if (!ctx) return; - const { segment } = ctx; - const segmentVal = this.#segmentValues[segment.type]; - if (!segmentVal.replace(/_/g, '')) return; - - this.#segmentValues[segment.type] = '_'.repeat(segment.len); - this.#refreshFromSegmentValues(); + if (!this.#segmentValues[ctx.segment.type].replace(/_/g, '')) { + this.#navigate(-1); + return; + } + this.#segmentValues[ctx.segment.type] = '_'.repeat(ctx.segment.len); + this.#segmentSelected = true; this.#cursor.positionInSegment = 0; + this.#refresh(); + return; + } + + // Tab navigation + if (key?.name === 'tab') { + this.inlineError = ''; + const ctx = this.#seg(); + if (!ctx) return; + const dir = key.shift ? -1 : 1; + const next = ctx.index + dir; + if (next >= 0 && next < this.#segments.length) { + this.#cursor.segmentIndex = next; + this.#cursor.positionInSegment = 0; + this.#segmentSelected = true; + } return; } + // Digit input if (char && /^[0-9]$/.test(char)) { - const ctx = this.#getCurrentSegment(); + const ctx = this.#seg(); if (!ctx) return; const { segment } = ctx; - const segmentDisplay = this.#segmentValues[segment.type]; + const isBlank = !this.#segmentValues[segment.type].replace(/_/g, ''); + + // Pending tens digit: complete the two-digit entry + if (this.#segmentSelected && this.#pendingTensDigit !== null && !isBlank) { + const newVal = this.#pendingTensDigit + char; + const newParts = { ...this.#segmentValues, [segment.type]: newVal }; + const err = this.#validateSegment(newParts, segment); + if (err) { + this.inlineError = err; + this.#pendingTensDigit = null; + this.#segmentSelected = false; + return; + } + this.inlineError = ''; + this.#segmentValues[segment.type] = newVal; + this.#pendingTensDigit = null; + this.#segmentSelected = false; + this.#refresh(); + if (ctx.index < this.#segments.length - 1) { + this.#cursor.segmentIndex = ctx.index + 1; + this.#cursor.positionInSegment = 0; + this.#segmentSelected = true; + } + return; + } - const firstBlank = segmentDisplay.indexOf('_'); - const pos = - firstBlank >= 0 ? firstBlank : Math.min(this.#cursor.positionInSegment, segment.len - 1); + // Clear-on-type: typing into a selected filled segment clears it first + if (this.#segmentSelected && !isBlank) { + this.#segmentValues[segment.type] = '_'.repeat(segment.len); + this.#cursor.positionInSegment = 0; + } + this.#segmentSelected = false; + this.#pendingTensDigit = null; + + const display = this.#segmentValues[segment.type]; + const firstBlank = display.indexOf('_'); + const pos = firstBlank >= 0 ? firstBlank : Math.min(this.#cursor.positionInSegment, segment.len - 1); if (pos < 0 || pos >= segment.len) return; - const newSegmentVal = segmentDisplay.slice(0, pos) + char + segmentDisplay.slice(pos + 1); + let newVal = display.slice(0, pos) + char + display.slice(pos + 1); - if (!newSegmentVal.includes('_')) { - const newParts = { - ...this.#segmentValues, - [segment.type]: newSegmentVal, - }; - const validationMsg = getSegmentValidationMessage(newParts, segment); - if (validationMsg) { - this.inlineError = validationMsg; + // Smart digit placement + let shouldStaySelected = false; + if (pos === 0 && display === '__' && (segment.type === 'month' || segment.type === 'day')) { + const digit = Number.parseInt(char, 10); + newVal = `0${char}`; + shouldStaySelected = digit <= (segment.type === 'month' ? 1 : 2); + } + if (segment.type === 'year') { + const digits = display.replace(/_/g, ''); + newVal = (digits + char).padStart(segment.len, '_'); + } + + if (!newVal.includes('_')) { + const newParts = { ...this.#segmentValues, [segment.type]: newVal }; + const err = this.#validateSegment(newParts, segment); + if (err) { + this.inlineError = err; return; } } this.inlineError = ''; - this.#segmentValues[segment.type] = newSegmentVal; - const iso = segmentValuesToISOString(this.#segmentValues); + this.#segmentValues[segment.type] = newVal; - if (iso) { - const { year, month, day } = segmentValuesToParsed(this.#segmentValues); + // Clamp only when the current segment is fully entered + const parsed = !newVal.includes('_') ? validParts(this.#segmentValues) : undefined; + if (parsed) { + const { year, month } = parsed; + const maxDay = daysInMonth(year, month); this.#segmentValues = { - year: String(clampSegment(year, 'year', { year, month })).padStart(4, '0'), - month: String(clampSegment(month, 'month', { year, month })).padStart(2, '0'), - day: String(clampSegment(day, 'day', { year, month })).padStart(2, '0'), + year: String(Math.max(0, Math.min(9999, year))).padStart(4, '0'), + month: String(Math.max(1, Math.min(12, month))).padStart(2, '0'), + day: String(Math.max(1, Math.min(maxDay, parsed.day))).padStart(2, '0'), }; } - this.#refreshFromSegmentValues(); - - const nextBlank = newSegmentVal.indexOf('_'); - const wasFilling = firstBlank >= 0; - if (nextBlank >= 0) { + this.#refresh(); + + // Advance cursor + const nextBlank = newVal.indexOf('_'); + if (shouldStaySelected) { + this.#segmentSelected = true; + this.#pendingTensDigit = char; + } else if (nextBlank >= 0) { this.#cursor.positionInSegment = nextBlank; - } else if (wasFilling && ctx.index < this.#config.segments.length - 1) { + } else if (firstBlank >= 0 && ctx.index < this.#segments.length - 1) { this.#cursor.segmentIndex = ctx.index + 1; this.#cursor.positionInSegment = 0; + this.#segmentSelected = true; } else { this.#cursor.positionInSegment = Math.min(pos + 1, segment.len - 1); } } } + #validateSegment(parts: DateParts, seg: SegmentConfig): string | undefined { + const { month, day } = parse(parts); + if (seg.type === 'month' && (month < 0 || month > 12)) { + return settings.date.messages.invalidMonth; + } + if (seg.type === 'day' && (day < 0 || day > 31)) { + return settings.date.messages.invalidDay(31, 'any month'); + } + return undefined; + } + #onFinalize(opts: DateOptions) { - this.value = segmentValuesToDate(this.#segmentValues) ?? opts.defaultValue ?? undefined; + const { year, month, day } = parse(this.#segmentValues); + if (year && month && day) { + const maxDay = daysInMonth(year, month); + this.#segmentValues = { + ...this.#segmentValues, + day: String(Math.min(day, maxDay)).padStart(2, '0'), + }; + } + this.value = toDate(this.#segmentValues) ?? opts.defaultValue ?? undefined; } } diff --git a/packages/core/src/utils/settings.ts b/packages/core/src/utils/settings.ts index b8394d27..b8b3222d 100644 --- a/packages/core/src/utils/settings.ts +++ b/packages/core/src/utils/settings.ts @@ -29,6 +29,7 @@ interface InternalClackSettings { monthNames: string[]; messages: { invalidMonth: string; + required: string; invalidDay: (days: number, month: string) => string; afterMin: (min: Date) => string; beforeMax: (max: Date) => string; @@ -56,6 +57,7 @@ export const settings: InternalClackSettings = { date: { monthNames: [...DEFAULT_MONTH_NAMES], messages: { + required: 'Please enter a valid date', invalidMonth: 'There are only 12 months in a year', invalidDay: (days, month) => `There are only ${days} days in ${month}`, afterMin: (min) => `Date must be on or after ${min.toISOString().slice(0, 10)}`, @@ -99,6 +101,8 @@ export interface ClackSettings { /** Month names for validation messages (January, February, ...) */ monthNames?: string[]; messages?: { + /** Shown when date is missing */ + required?: string; /** Shown when month > 12 */ invalidMonth?: string; /** (days, monthName) => message for invalid day */ @@ -147,6 +151,9 @@ export function updateSettings(updates: ClackSettings) { settings.date.monthNames = [...date.monthNames]; } if (date.messages !== undefined) { + if (date.messages.required !== undefined) { + settings.date.messages.required = date.messages.required; + } if (date.messages.invalidMonth !== undefined) { settings.date.messages.invalidMonth = date.messages.invalidMonth; } diff --git a/packages/core/test/prompts/date.test.ts b/packages/core/test/prompts/date.test.ts index 0db5a42e..93cf6055 100644 --- a/packages/core/test/prompts/date.test.ts +++ b/packages/core/test/prompts/date.test.ts @@ -1,35 +1,10 @@ import { cursor } from 'sisteransi'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; -import type { DateFormatConfig, DateParts } from '../../src/prompts/date.js'; import { default as DatePrompt } from '../../src/prompts/date.js'; import { isCancel } from '../../src/utils/index.js'; import { MockReadable } from '../mock-readable.js'; import { MockWritable } from '../mock-writable.js'; -function buildFormatConfig( - format: (p: DateParts) => string, - types: ('year' | 'month' | 'day')[] -): DateFormatConfig { - const segments = types.map((type) => { - const len = type === 'year' ? 4 : 2; - return { type, len }; - }); - return { segments, format }; -} - -const YYYY_MM_DD = buildFormatConfig( - (p) => `${p.year}/${p.month}/${p.day}`, - ['year', 'month', 'day'] -); -const MM_DD_YYYY = buildFormatConfig( - (p) => `${p.month}/${p.day}/${p.year}`, - ['month', 'day', 'year'] -); -const DD_MM_YYYY = buildFormatConfig( - (p) => `${p.day}/${p.month}/${p.year}`, - ['day', 'month', 'year'] -); - const d = (iso: string) => { const [y, m, day] = iso.slice(0, 10).split('-').map(Number); return new Date(Date.UTC(y, m - 1, day)); @@ -53,7 +28,7 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', }); instance.prompt(); expect(output.buffer).to.deep.equal([cursor.hide, 'foo']); @@ -64,13 +39,13 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', initialValue: d('2025-01-15'), }); instance.prompt(); expect(instance.userInput).to.equal('2025/01/15'); expect(instance.value).toBeInstanceOf(Date); - expect(instance.value?.toISOString().slice(0, 10)).to.equal('2025-01-15'); + expect(instance.value!.toISOString().slice(0, 10)).to.equal('2025-01-15'); }); test('left/right navigates between segments', () => { @@ -78,34 +53,17 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', initialValue: d('2025-01-15'), }); instance.prompt(); - expect(instance.segmentCursor).to.deep.equal({ - segmentIndex: 0, - positionInSegment: 0, - }); - // Move within year (0->1->2->3), then right from end goes to month - for (let i = 0; i < 4; i++) { - input.emit('keypress', undefined, { name: 'right' }); - } - expect(instance.segmentCursor).to.deep.equal({ - segmentIndex: 1, - positionInSegment: 0, - }); - for (let i = 0; i < 2; i++) { - input.emit('keypress', undefined, { name: 'right' }); - } - expect(instance.segmentCursor).to.deep.equal({ - segmentIndex: 2, - positionInSegment: 0, - }); + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); + input.emit('keypress', undefined, { name: 'right' }); + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); + input.emit('keypress', undefined, { name: 'right' }); + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 2, positionInSegment: 0 }); input.emit('keypress', undefined, { name: 'left' }); - expect(instance.segmentCursor).to.deep.equal({ - segmentIndex: 1, - positionInSegment: 0, - }); + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); }); test('up/down increments and decrements segment', () => { @@ -113,11 +71,11 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', initialValue: d('2025-01-15'), }); instance.prompt(); - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month + input.emit('keypress', undefined, { name: 'right' }); // move to month input.emit('keypress', undefined, { name: 'up' }); expect(instance.userInput).to.equal('2025/02/15'); input.emit('keypress', undefined, { name: 'down' }); @@ -129,18 +87,15 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', }); instance.prompt(); expect(instance.userInput).to.equal('____/__/__'); input.emit('keypress', undefined, { name: 'up' }); // up on year (first segment) - expect(instance.userInput).to.equal('1000/__/__'); - input.emit('keypress', undefined, { name: 'right' }); - input.emit('keypress', undefined, { name: 'right' }); - input.emit('keypress', undefined, { name: 'right' }); + expect(instance.userInput).to.equal('0001/__/__'); input.emit('keypress', undefined, { name: 'right' }); // move to month input.emit('keypress', undefined, { name: 'up' }); - expect(instance.userInput).to.equal('1000/01/__'); + expect(instance.userInput).to.equal('0001/01/__'); }); test('with minDate/maxDate, up on blank segment starts at min', () => { @@ -148,7 +103,7 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', minDate: d('2025-03-10'), maxDate: d('2025-11-20'), }); @@ -156,10 +111,10 @@ describe('DatePrompt', () => { expect(instance.userInput).to.equal('____/__/__'); input.emit('keypress', undefined, { name: 'up' }); expect(instance.userInput).to.equal('2025/__/__'); - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); + input.emit('keypress', undefined, { name: 'right' }); input.emit('keypress', undefined, { name: 'up' }); expect(instance.userInput).to.equal('2025/03/__'); - for (let i = 0; i < 2; i++) input.emit('keypress', undefined, { name: 'right' }); + input.emit('keypress', undefined, { name: 'right' }); input.emit('keypress', undefined, { name: 'up' }); expect(instance.userInput).to.equal('2025/03/10'); }); @@ -169,17 +124,17 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', minDate: d('2025-03-10'), maxDate: d('2025-11-20'), }); instance.prompt(); input.emit('keypress', undefined, { name: 'down' }); expect(instance.userInput).to.equal('2025/__/__'); - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); + input.emit('keypress', undefined, { name: 'right' }); input.emit('keypress', undefined, { name: 'down' }); expect(instance.userInput).to.equal('2025/11/__'); - for (let i = 0; i < 2; i++) input.emit('keypress', undefined, { name: 'right' }); + input.emit('keypress', undefined, { name: 'right' }); input.emit('keypress', undefined, { name: 'down' }); expect(instance.userInput).to.equal('2025/11/20'); }); @@ -189,24 +144,20 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', initialValue: d('2025-01-15'), }); instance.prompt(); - expect(instance.segmentCursor).to.deep.equal({ - segmentIndex: 0, - positionInSegment: 0, - }); - // Type 2,0,2,3 to change 2025 -> 2023 (edit digit by digit) + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); + // Type 2,0,2,3 to change year to 2023 (right-to-left fill) input.emit('keypress', '2', { name: undefined, sequence: '2' }); + expect(instance.userInput).to.equal('___2/01/15'); input.emit('keypress', '0', { name: undefined, sequence: '0' }); + expect(instance.userInput).to.equal('__20/01/15'); input.emit('keypress', '2', { name: undefined, sequence: '2' }); + expect(instance.userInput).to.equal('_202/01/15'); input.emit('keypress', '3', { name: undefined, sequence: '3' }); expect(instance.userInput).to.equal('2023/01/15'); - expect(instance.segmentCursor).to.deep.equal({ - segmentIndex: 0, - positionInSegment: 3, - }); }); test('backspace clears entire segment at any cursor position', () => { @@ -214,22 +165,14 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', initialValue: d('2025-12-21'), }); instance.prompt(); expect(instance.userInput).to.equal('2025/12/21'); - expect(instance.segmentCursor).to.deep.equal({ - segmentIndex: 0, - positionInSegment: 0, - }); - // Backspace at first position clears whole year segment input.emit('keypress', undefined, { name: 'backspace', sequence: '\x7f' }); expect(instance.userInput).to.equal('____/12/21'); - expect(instance.segmentCursor).to.deep.equal({ - segmentIndex: 0, - positionInSegment: 0, - }); + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); }); test('backspace clears segment when cursor at first char (2___)', () => { @@ -237,29 +180,14 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', }); instance.prompt(); - // Type "2" to get "2___" input.emit('keypress', '2', { name: undefined, sequence: '2' }); - expect(instance.userInput).to.equal('2___/__/__'); - expect(instance.segmentCursor).to.deep.equal({ - segmentIndex: 0, - positionInSegment: 1, - }); - // Move to first char (position 0) - input.emit('keypress', undefined, { name: 'left' }); - expect(instance.segmentCursor).to.deep.equal({ - segmentIndex: 0, - positionInSegment: 0, - }); - // Backspace should clear whole segment - also test char-based detection + expect(instance.userInput).to.equal('___2/__/__'); input.emit('keypress', '\x7f', { name: undefined, sequence: '\x7f' }); expect(instance.userInput).to.equal('____/__/__'); - expect(instance.segmentCursor).to.deep.equal({ - segmentIndex: 0, - positionInSegment: 0, - }); + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 0, positionInSegment: 0 }); }); test('digit input updates segment and jumps to next when complete', () => { @@ -267,26 +195,22 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', }); instance.prompt(); - // Type year 2025 - left-to-right, jumps to month when year complete for (const c of '2025') { input.emit('keypress', c, { name: undefined, sequence: c }); } expect(instance.userInput).to.equal('2025/__/__'); - expect(instance.segmentCursor).to.deep.equal({ - segmentIndex: 1, - positionInSegment: 0, - }); + expect(instance.segmentCursor).to.deep.equal({ segmentIndex: 1, positionInSegment: 0 }); }); - test('submit returns ISO string for valid date', async () => { + test('submit returns Date for valid date', async () => { const instance = new DatePrompt({ input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', initialValue: d('2025-01-31'), }); const resultPromise = instance.prompt(); @@ -301,7 +225,7 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', initialValue: d('2025-01-15'), }); const resultPromise = instance.prompt(); @@ -315,7 +239,7 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', defaultValue: d('2025-06-15'), }); const resultPromise = instance.prompt(); @@ -325,59 +249,63 @@ describe('DatePrompt', () => { expect((result as Date).toISOString().slice(0, 10)).to.equal('2025-06-15'); }); - test('supports MM/DD/YYYY format', () => { + test('supports MDY format', () => { const instance = new DatePrompt({ input, output, render: () => 'foo', - formatConfig: MM_DD_YYYY, + format: 'MDY', initialValue: d('2025-01-15'), }); instance.prompt(); expect(instance.userInput).to.equal('01/15/2025'); }); - test('rejects invalid month and shows inline error', () => { + test('supports DMY format', () => { const instance = new DatePrompt({ input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, - initialValue: d('2025-01-15'), // month is 01 + format: 'DMY', + initialValue: d('2025-01-15'), }); instance.prompt(); - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month (cursor at start) - input.emit('keypress', '3', { name: undefined, sequence: '3' }); // 0→3 gives 31, invalid - expect(instance.userInput).to.equal('2025/01/15'); // stayed - 31 rejected - expect(instance.inlineError).to.equal('There are only 12 months in a year'); + expect(instance.userInput).to.equal('15/01/2025'); }); - test('rejects invalid day and shows inline error', () => { + test('rejects invalid month via pending tens digit', () => { const instance = new DatePrompt({ input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, - initialValue: d('2025-01-15'), // January has 31 days + format: 'YMD', }); instance.prompt(); - for (let i = 0; i < 6; i++) input.emit('keypress', undefined, { name: 'right' }); // move to day (cursor at start) - input.emit('keypress', '4', { name: undefined, sequence: '4' }); // 1→4 gives 45, invalid for Jan - expect(instance.userInput).to.equal('2025/01/15'); // stayed - 45 rejected - expect(instance.inlineError).to.contain('31 days'); - expect(instance.inlineError).to.contain('January'); + // Navigate to month + input.emit('keypress', undefined, { name: 'right' }); + // Type '1' → '01' with pending tens digit (since 1 <= 1) + input.emit('keypress', '1', { name: undefined, sequence: '1' }); + expect(instance.segmentValues.month).to.equal('01'); + // Type '3' → tries '13' which is > 12 → inline error + input.emit('keypress', '3', { name: undefined, sequence: '3' }); + expect(instance.inlineError).to.equal('There are only 12 months in a year'); }); - test('supports DD/MM/YYYY format', () => { + test('rejects invalid day via pending tens digit', () => { const instance = new DatePrompt({ input, output, render: () => 'foo', - formatConfig: DD_MM_YYYY, - initialValue: d('2025-01-15'), + format: 'YMD', }); instance.prompt(); - expect(instance.userInput).to.equal('15/01/2025'); + // Navigate to day + input.emit('keypress', undefined, { name: 'right' }); + input.emit('keypress', undefined, { name: 'right' }); + // Type '2' → '02' with pending (2 <= 2) + input.emit('keypress', '2', { name: undefined, sequence: '2' }); + input.emit('keypress', '0', { name: undefined, sequence: '0' }); + expect(instance.inlineError).to.equal(''); }); describe('segmentValues and segmentCursor', () => { @@ -386,7 +314,7 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', initialValue: d('2025-01-15'), }); instance.prompt(); @@ -401,14 +329,14 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', initialValue: d('2025-01-15'), }); instance.prompt(); - for (let i = 0; i < 4; i++) input.emit('keypress', undefined, { name: 'right' }); // move to month + input.emit('keypress', undefined, { name: 'right' }); // move to month const cursor = instance.segmentCursor; - expect(cursor.segmentIndex).to.equal(1); // month segment - expect(cursor.positionInSegment).to.equal(0); // start of segment + expect(cursor.segmentIndex).to.equal(1); + expect(cursor.positionInSegment).to.equal(0); }); test('segmentValues updates on submit', () => { @@ -416,7 +344,7 @@ describe('DatePrompt', () => { input, output, render: () => 'foo', - formatConfig: YYYY_MM_DD, + format: 'YMD', initialValue: d('2025-01-15'), }); instance.prompt(); @@ -427,4 +355,74 @@ describe('DatePrompt', () => { expect(segmentValues.day).to.equal('15'); }); }); + + describe('formattedValue and segments', () => { + test('formattedValue returns formatted string', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + format: 'MDY', + initialValue: d('2025-03-15'), + }); + instance.prompt(); + expect(instance.formattedValue).to.equal('03/15/2025'); + }); + + test('segments exposes segment config', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + format: 'DMY', + }); + instance.prompt(); + expect(instance.segments).to.deep.equal([ + { type: 'day', len: 2 }, + { type: 'month', len: 2 }, + { type: 'year', len: 4 }, + ]); + }); + + test('separator defaults to / for explicit format', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + format: 'YMD', + }); + instance.prompt(); + expect(instance.separator).to.equal('/'); + }); + }); + + describe('locale detection', () => { + test('locale auto-detects format from Intl', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + locale: 'en-US', + initialValue: d('2025-03-15'), + }); + instance.prompt(); + // en-US is MDY + expect(instance.segments[0].type).to.equal('month'); + expect(instance.segments[1].type).to.equal('day'); + expect(instance.segments[2].type).to.equal('year'); + }); + + test('explicit format overrides locale', () => { + const instance = new DatePrompt({ + input, + output, + render: () => 'foo', + format: 'YMD', + locale: 'en-US', // would be MDY, but format takes precedence + initialValue: d('2025-03-15'), + }); + instance.prompt(); + expect(instance.segments[0].type).to.equal('year'); + }); + }); }); diff --git a/packages/prompts/src/date.ts b/packages/prompts/src/date.ts index 62c2e700..e97bc667 100644 --- a/packages/prompts/src/date.ts +++ b/packages/prompts/src/date.ts @@ -1,115 +1,14 @@ import { styleText } from 'node:util'; -import type { DateFormatConfig, DateParts } from '@clack/core'; +import type { DateFormat, State } from '@clack/core'; import { DatePrompt, settings } from '@clack/core'; import { type CommonOptions, S_BAR, S_BAR_END, symbol } from './common.js'; -export type DateFormat = 'YYYY/MM/DD' | 'MM/DD/YYYY' | 'DD/MM/YYYY'; - -type CursorState = { segmentIndex: number; positionInSegment: number }; -type RenderState = 'active' | 'submit' | 'cancel' | 'error'; - -const DEFAULT_SEGMENT_LABELS: Record<'year' | 'month' | 'day', string> = { - year: 'yyyy', - month: 'mm', - day: 'dd', -}; - -/** Derive a plain formatter from segment order -- single source of truth */ -function makePlainFormatter(segments: DateFormatConfig['segments']): (parts: DateParts) => string { - return (p) => segments.map((s) => p[s.type]).join('/'); -} - -/** Render a single segment with cursor highlighting */ -function renderSegment( - value: string, - segmentIndex: number, - cursor: CursorState, - label: string, - state: RenderState -): string { - const isBlank = !value || value.replace(/_/g, '') === ''; - const cursorInThis = - segmentIndex === cursor.segmentIndex && state !== 'submit' && state !== 'cancel'; - const parts: string[] = []; - - if (isBlank) { - if (cursorInThis) { - for (let j = 0; j < label.length; j++) { - parts.push( - j === cursor.positionInSegment ? styleText('inverse', ' ') : styleText('dim', label[j]) - ); - } - } else { - parts.push(styleText('dim', label)); - } - } else { - for (let j = 0; j < value.length; j++) { - if (cursorInThis && j === cursor.positionInSegment) { - parts.push(value[j] === '_' ? styleText('inverse', ' ') : styleText('inverse', value[j])); - } else { - parts.push(value[j] === '_' ? styleText('dim', ' ') : value[j]); - } - } - } - - return parts.join(''); -} - -/** Generic data-driven renderer -- iterates segments from config, no per-format duplication */ -function renderDateFormat( - parts: DateParts, - cursor: CursorState, - state: RenderState, - config: DateFormatConfig -): string { - if (state === 'submit' || state === 'cancel') { - return config.format(parts); - } - const labels = config.segmentLabels ?? DEFAULT_SEGMENT_LABELS; - const sep = styleText('gray', '/'); - const rendered = config.segments.map((seg, i) => - renderSegment(parts[seg.type], i, cursor, labels[seg.type], state) - ); - let result = rendered.join(sep); - const lastSeg = config.segments[config.segments.length - 1]; - if ( - cursor.segmentIndex >= config.segments.length || - (cursor.segmentIndex === config.segments.length - 1 && cursor.positionInSegment >= lastSeg.len) - ) { - result += '█'; - } - return result; -} - -/** Segment definitions per format -- the single source of truth for order and lengths */ -const SEGMENT_DEFS: Record = { - 'YYYY/MM/DD': [ - { type: 'year', len: 4 }, - { type: 'month', len: 2 }, - { type: 'day', len: 2 }, - ], - 'MM/DD/YYYY': [ - { type: 'month', len: 2 }, - { type: 'day', len: 2 }, - { type: 'year', len: 4 }, - ], - 'DD/MM/YYYY': [ - { type: 'day', len: 2 }, - { type: 'month', len: 2 }, - { type: 'year', len: 4 }, - ], -}; - -/** Pre-computed format configs derived from segment definitions */ -const FORMAT_CONFIGS: Record = Object.fromEntries( - (Object.entries(SEGMENT_DEFS) as [DateFormat, DateFormatConfig['segments']][]).map( - ([key, segments]) => [key, { segments, format: makePlainFormatter(segments) }] - ) -) as Record; +export type { DateFormat }; export interface DateOptions extends CommonOptions { message: string; format?: DateFormat; + locale?: string; defaultValue?: Date; initialValue?: Date; minDate?: Date; @@ -119,87 +18,103 @@ export interface DateOptions extends CommonOptions { export const date = (opts: DateOptions) => { const validate = opts.validate; - const formatConfig = FORMAT_CONFIGS[opts.format ?? 'YYYY/MM/DD']; return new DatePrompt({ - formatConfig, - defaultValue: opts.defaultValue, - initialValue: opts.initialValue, - minDate: opts.minDate, - maxDate: opts.maxDate, + ...opts, validate(value: Date | undefined) { if (value === undefined) { if (opts.defaultValue !== undefined) return undefined; if (validate) return validate(value); - return 'Please enter a valid date'; + return settings.date.messages.required; } - const dateOnly = (d: Date) => d.toISOString().slice(0, 10); - if (opts.minDate && dateOnly(value) < dateOnly(opts.minDate)) { + const iso = (d: Date) => d.toISOString().slice(0, 10); + if (opts.minDate && iso(value) < iso(opts.minDate)) { return settings.date.messages.afterMin(opts.minDate); } - if (opts.maxDate && dateOnly(value) > dateOnly(opts.maxDate)) { + if (opts.maxDate && iso(value) > iso(opts.maxDate)) { return settings.date.messages.beforeMax(opts.maxDate); } if (validate) return validate(value); return undefined; }, - signal: opts.signal, - input: opts.input, - output: opts.output, render() { const hasGuide = (opts?.withGuide ?? settings.withGuide) !== false; const titlePrefix = `${hasGuide ? `${styleText('gray', S_BAR)}\n` : ''}${symbol(this.state)} `; const title = `${titlePrefix}${opts.message}\n`; - const segmentValues = this.segmentValues; - const segmentCursor = this.segmentCursor; - - const renderState: RenderState = - this.state === 'submit' - ? 'submit' - : this.state === 'cancel' - ? 'cancel' - : this.state === 'error' - ? 'error' - : 'active'; + const state = this.state !== 'initial' ? this.state : 'active'; - const userInput = renderDateFormat(segmentValues, segmentCursor, renderState, formatConfig); - - const value = - this.value instanceof Date - ? formatConfig.format({ - year: String(this.value.getFullYear()).padStart(4, '0'), - month: String(this.value.getMonth() + 1).padStart(2, '0'), - day: String(this.value.getDate()).padStart(2, '0'), - }) - : ''; + const userInput = renderDate(this, state); + const value = this.value instanceof Date ? this.formattedValue : ''; switch (this.state) { case 'error': { const errorText = this.error ? ` ${styleText('yellow', this.error)}` : ''; - const errorPrefix = hasGuide ? `${styleText('yellow', S_BAR)} ` : ''; - const errorPrefixEnd = hasGuide ? styleText('yellow', S_BAR_END) : ''; - return `${title.trim()}\n${errorPrefix}${userInput}\n${errorPrefixEnd}${errorText}\n`; + const bar = hasGuide ? `${styleText('yellow', S_BAR)} ` : ''; + const barEnd = hasGuide ? styleText('yellow', S_BAR_END) : ''; + return `${title.trim()}\n${bar}${userInput}\n${barEnd}${errorText}\n`; } case 'submit': { const valueText = value ? ` ${styleText('dim', value)}` : ''; - const submitPrefix = hasGuide ? styleText('gray', S_BAR) : ''; - return `${title}${submitPrefix}${valueText}`; + const bar = hasGuide ? styleText('gray', S_BAR) : ''; + return `${title}${bar}${valueText}`; } case 'cancel': { const valueText = value ? ` ${styleText(['strikethrough', 'dim'], value)}` : ''; - const cancelPrefix = hasGuide ? styleText('gray', S_BAR) : ''; - return `${title}${cancelPrefix}${valueText}${value.trim() ? `\n${cancelPrefix}` : ''}`; + const bar = hasGuide ? styleText('gray', S_BAR) : ''; + return `${title}${bar}${valueText}${value.trim() ? `\n${bar}` : ''}`; } default: { - const defaultPrefix = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''; - const defaultPrefixEnd = hasGuide ? styleText('cyan', S_BAR_END) : ''; - const inlineErrorBar = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''; - const inlineError = (this as { inlineError?: string }).inlineError - ? `\n${inlineErrorBar}${styleText('yellow', (this as { inlineError: string }).inlineError)}` + const bar = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''; + const barEnd = hasGuide ? styleText('cyan', S_BAR_END) : ''; + const inlineBar = hasGuide ? `${styleText('cyan', S_BAR)} ` : ''; + const inlineError = this.inlineError + ? `\n${inlineBar}${styleText('yellow', this.inlineError)}` : ''; - return `${title}${defaultPrefix}${userInput}${inlineError}\n${defaultPrefixEnd}\n`; + return `${title}${bar}${userInput}${inlineError}\n${barEnd}\n`; } } }, }).prompt() as Promise; }; + + +function renderDate( + prompt: Omit, 'prompt'>, + state: State +): string { + const parts = prompt.segmentValues; + const cursor = prompt.segmentCursor; + + if (state === 'submit' || state === 'cancel') { + return prompt.formattedValue; + } + + const sep = styleText('gray', prompt.separator); + return prompt.segments + .map((seg, i) => { + const isActive = i === cursor.segmentIndex && !['submit', 'cancel'].includes(state); + const label = DEFAULT_LABELS[seg.type]; + return renderSegment(parts[seg.type], { isActive, label }); + }) + .join(sep); +} + +interface SegmentOptions { + isActive: boolean; + label: string; +} +function renderSegment( + value: string, + opts: SegmentOptions, +): string { + const isBlank = !value || value.replace(/_/g, '') === ''; + if (opts.isActive) return styleText('inverse', isBlank ? opts.label : value.replace(/_/g, ' ')); + if (isBlank) return styleText('dim', opts.label); + return value.replace(/_/g, styleText('dim', ' ')); +} + +const DEFAULT_LABELS: Record<'year' | 'month' | 'day', string> = { + year: 'yyyy', + month: 'mm', + day: 'dd', +}; diff --git a/packages/prompts/test/__snapshots__/date.test.ts.snap b/packages/prompts/test/__snapshots__/date.test.ts.snap index 1733127e..6af6422b 100644 --- a/packages/prompts/test/__snapshots__/date.test.ts.snap +++ b/packages/prompts/test/__snapshots__/date.test.ts.snap @@ -5,7 +5,7 @@ exports[`date (isCI = false) > can cancel 1`] = ` "", "│ ◆ Pick a date -│  yyy/mm/dd +│ mm/dd/yyyy └ ", "", @@ -24,14 +24,14 @@ exports[`date (isCI = false) > defaultValue used when empty submit 1`] = ` "", "│ ◆ Pick a date -│ 2025/12/25 +│ 12/25/2025 └ ", "", "", "", "◇ Pick a date -│ 2025/12/25", +│ 12/25/2025", " ", "", @@ -43,14 +43,14 @@ exports[`date (isCI = false) > renders initial value 1`] = ` "", "│ ◆ Pick a date -│ 2025/01/15 +│ 01/15/2025 └ ", "", "", "", "◇ Pick a date -│ 2025/01/15", +│ 01/15/2025", " ", "", @@ -62,14 +62,14 @@ exports[`date (isCI = false) > renders message 1`] = ` "", "│ ◆ Pick a date -│ 2025/01/15 +│ 01/15/2025 └ ", "", "", "", "◇ Pick a date -│ 2025/01/15", +│ 01/15/2025", " ", "", @@ -81,26 +81,26 @@ exports[`date (isCI = false) > renders submitted value 1`] = ` "", "│ ◆ Pick a date -│ 2025/06/15 +│ 06/15/2025 └ ", "", "", "", "◇ Pick a date -│ 2025/06/15", +│ 06/15/2025", " ", "", ] `; -exports[`date (isCI = false) > supports MM/DD/YYYY format 1`] = ` +exports[`date (isCI = false) > supports MDY format 1`] = ` [ "", "│ ◆ Pick a date -│ 01/15/2025 +│ 01/15/2025 └ ", "", @@ -118,13 +118,13 @@ exports[`date (isCI = false) > withGuide: false removes guide 1`] = ` [ "", "◆ Pick a date -2025/01/15 +01/15/2025 ", "", "", "◇ Pick a date - 2025/01/15", + 01/15/2025", " ", "", @@ -136,7 +136,7 @@ exports[`date (isCI = true) > can cancel 1`] = ` "", "│ ◆ Pick a date -│  yyy/mm/dd +│ mm/dd/yyyy └ ", "", @@ -155,14 +155,14 @@ exports[`date (isCI = true) > defaultValue used when empty submit 1`] = ` "", "│ ◆ Pick a date -│ 2025/12/25 +│ 12/25/2025 └ ", "", "", "", "◇ Pick a date -│ 2025/12/25", +│ 12/25/2025", " ", "", @@ -174,14 +174,14 @@ exports[`date (isCI = true) > renders initial value 1`] = ` "", "│ ◆ Pick a date -│ 2025/01/15 +│ 01/15/2025 └ ", "", "", "", "◇ Pick a date -│ 2025/01/15", +│ 01/15/2025", " ", "", @@ -193,14 +193,14 @@ exports[`date (isCI = true) > renders message 1`] = ` "", "│ ◆ Pick a date -│ 2025/01/15 +│ 01/15/2025 └ ", "", "", "", "◇ Pick a date -│ 2025/01/15", +│ 01/15/2025", " ", "", @@ -212,26 +212,26 @@ exports[`date (isCI = true) > renders submitted value 1`] = ` "", "│ ◆ Pick a date -│ 2025/06/15 +│ 06/15/2025 └ ", "", "", "", "◇ Pick a date -│ 2025/06/15", +│ 06/15/2025", " ", "", ] `; -exports[`date (isCI = true) > supports MM/DD/YYYY format 1`] = ` +exports[`date (isCI = true) > supports MDY format 1`] = ` [ "", "│ ◆ Pick a date -│ 01/15/2025 +│ 01/15/2025 └ ", "", @@ -249,13 +249,13 @@ exports[`date (isCI = true) > withGuide: false removes guide 1`] = ` [ "", "◆ Pick a date -2025/01/15 +01/15/2025 ", "", "", "◇ Pick a date - 2025/01/15", + 01/15/2025", " ", "", diff --git a/packages/prompts/test/date.test.ts b/packages/prompts/test/date.test.ts index 339e77fa..40950fef 100644 --- a/packages/prompts/test/date.test.ts +++ b/packages/prompts/test/date.test.ts @@ -129,10 +129,10 @@ describe.each(['true', 'false'])('date (isCI = %s)', (isCI) => { expect(output.buffer).toMatchSnapshot(); }); - test('supports MM/DD/YYYY format', async () => { + test('supports MDY format', async () => { const result = prompts.date({ message: 'Pick a date', - format: 'MM/DD/YYYY', + format: 'MDY', initialValue: d('2025-01-15'), input, output,