diff --git a/src/engine/MacroChoiceEngine.entry.test.ts b/src/engine/MacroChoiceEngine.entry.test.ts index e46a1901..dc63f85d 100644 --- a/src/engine/MacroChoiceEngine.entry.test.ts +++ b/src/engine/MacroChoiceEngine.entry.test.ts @@ -45,6 +45,12 @@ vi.mock("../gui/GenericInputPrompt/GenericInputPrompt", () => ({ Prompt: mockInputPrompt, }, })); +vi.mock("../gui/VDateInputPrompt/VDateInputPrompt", () => ({ + __esModule: true, + default: { + Prompt: vi.fn(), + }, +})); vi.mock("../gui/GenericCheckboxPrompt/genericCheckboxPrompt", () => ({ __esModule: true, default: { Open: vi.fn() }, diff --git a/src/formatters/formatter.ts b/src/formatters/formatter.ts index b9bed109..b4fa0480 100644 --- a/src/formatters/formatter.ts +++ b/src/formatters/formatter.ts @@ -472,24 +472,30 @@ export abstract class Formatter { variableName, { type: "VDATE", dateFormat, defaultValue } ); - this.variables.set(variableName, dateInput); - - if (!this.dateParser) throw new Error("Date parser is not available"); - - const aliasMap = settingsStore.getState().dateAliases; - const normalizedInput = normalizeDateInput(dateInput, aliasMap); - const parseAttempt = this.dateParser.parseDate(normalizedInput); - - if (parseAttempt) { - // Store the ISO string with a special prefix - this.variables.set( - variableName, - `@date:${parseAttempt.moment.toISOString()}`, - ); + if (dateInput?.startsWith("@date:")) { + this.variables.set(variableName, dateInput); } else { - throw new Error( - `unable to parse date variable ${dateInput}`, + if (!this.dateParser) + throw new Error("Date parser is not available"); + + const aliasMap = settingsStore.getState().dateAliases; + const normalizedInput = normalizeDateInput( + dateInput, + aliasMap, ); + const parseAttempt = this.dateParser.parseDate(normalizedInput); + + if (parseAttempt) { + // Store the ISO string with a special prefix + this.variables.set( + variableName, + `@date:${parseAttempt.moment.toISOString()}`, + ); + } else { + throw new Error( + `unable to parse date variable ${dateInput}`, + ); + } } } diff --git a/src/formatters/vdate-default.test.ts b/src/formatters/vdate-default.test.ts index 5d79a289..c9ed17b0 100644 --- a/src/formatters/vdate-default.test.ts +++ b/src/formatters/vdate-default.test.ts @@ -189,6 +189,16 @@ describe('VDATE Default Value Support', () => { expect(result).toBe("Test YYYY-MM-DD-formatted"); }); + it('should accept @date: values returned from prompts', async () => { + const input = "Test {{VDATE:date,YYYY-MM-DD}}"; + formatter.setMockPromptValue("@date:2025-08-01T00:00:00.000Z"); + + const result = await formatter.testReplaceDateVariableInString(input); + + expect(formatter.testDateParser.parseDate).not.toHaveBeenCalled(); + expect(result).toBe("Test YYYY-MM-DD-formatted"); + }); + it('should use user input over default value', async () => { const input = "Test {{VDATE:date,YYYY-MM-DD|today}}"; formatter.setMockPromptValue("tomorrow"); diff --git a/src/gui/GenericInputPrompt/GenericInputPrompt.ts b/src/gui/GenericInputPrompt/GenericInputPrompt.ts index 96dc8a44..c0b812cf 100644 --- a/src/gui/GenericInputPrompt/GenericInputPrompt.ts +++ b/src/gui/GenericInputPrompt/GenericInputPrompt.ts @@ -10,7 +10,7 @@ export default class GenericInputPrompt extends Modal { private resolvePromise: (input: string) => void; private rejectPromise: (reason?: unknown) => void; private didSubmit = false; - private inputComponent: TextComponent; + protected inputComponent: TextComponent; protected input: string; private readonly placeholder: string; private readonly draftHandler: InputPromptDraftHandler; @@ -174,8 +174,13 @@ export default class GenericInputPrompt extends Modal { } }; + protected transformInputOnSubmit(input: string): string { + return input; + } + private submit() { - this.input = this.inputComponent?.inputEl?.value ?? this.input; + const rawInput = this.inputComponent?.inputEl?.value ?? this.input; + this.input = this.transformInputOnSubmit(rawInput); this.didSubmit = true; this.close(); diff --git a/src/gui/VDateInputPrompt/VDateInputPrompt.ts b/src/gui/VDateInputPrompt/VDateInputPrompt.ts index d2ab60ff..d76a6b4a 100644 --- a/src/gui/VDateInputPrompt/VDateInputPrompt.ts +++ b/src/gui/VDateInputPrompt/VDateInputPrompt.ts @@ -1,7 +1,8 @@ import type { App, Debouncer } from "obsidian"; import { TextComponent, debounce } from "obsidian"; import GenericInputPrompt from "../GenericInputPrompt/GenericInputPrompt"; -import { parseNaturalLanguageDate } from "../../utils/dateParser"; +import { createDatePicker, type DatePickerController } from "../date-picker/datePicker"; +import { formatISODate, parseNaturalLanguageDate } from "../../utils/dateParser"; import { settingsStore } from "../../settingsStore"; import { formatDateAliasInline, @@ -15,6 +16,9 @@ export default class VDateInputPrompt extends GenericInputPrompt { private currentInput: string; private isOpen = true; private defaultValue: string | undefined; + private datePicker?: DatePickerController; + private selectedIso?: string; + private lastPickerDisplayValue?: string; private static readonly PREVIEW_PLACEHOLDER = "Preview will appear here"; public static Prompt( @@ -43,11 +47,12 @@ export default class VDateInputPrompt extends GenericInputPrompt { ) { // Pass the defaultValue to the parent so the input box is pre-filled super(app, header, placeholder, defaultValue ?? ""); - + + this.containerEl.addClass("qaDatePrompt"); this.dateFormat = dateFormat || "YYYY-MM-DD"; this.defaultValue = defaultValue; this.currentInput = defaultValue ?? ""; - + // Create debounced preview update function (250ms delay, reset on each call) this.updatePreviewDebounced = debounce( this.updatePreview.bind(this), @@ -55,10 +60,7 @@ export default class VDateInputPrompt extends GenericInputPrompt { true // Reset timer on each call (standard debounce behavior) ); - // Trigger initial preview update now that all fields are properly set - if (this.defaultValue) { - this.updatePreview(); - } + this.updatePreview(); } protected createInputField( @@ -66,6 +68,8 @@ export default class VDateInputPrompt extends GenericInputPrompt { placeholder?: string, value?: string ) { + container.addClass("qa-date-input"); + // Create TextComponent directly to avoid duplicate onChange listeners const textComponent = new TextComponent(container); @@ -74,6 +78,7 @@ export default class VDateInputPrompt extends GenericInputPrompt { .setPlaceholder(placeholder ?? "") .setValue(value ?? "") .onChange((newValue) => { + this.lastPickerDisplayValue = undefined; this.onInputChanged(newValue); this.currentInput = newValue; this.updatePreviewDebounced(); @@ -82,13 +87,30 @@ export default class VDateInputPrompt extends GenericInputPrompt { // Initialize currentInput with the initial value (which should be defaultValue) this.currentInput = value ?? ""; - + + this.createDatePicker(container); + // Create preview element this.createPreviewElement(container); - + return textComponent; } + private createDatePicker(container: HTMLElement) { + const pickerContainer = container.createDiv({ + cls: "qa-date-picker-container", + }); + + this.datePicker = createDatePicker({ + container: pickerContainer, + initialIso: this.selectedIso, + onSelect: (iso) => { + if (iso) this.applyPickerSelection(iso); + else this.clearPickerSelection(); + }, + }); + } + private createPreviewElement(container: HTMLElement) { const previewContainer = container.createDiv("vdate-preview-container"); previewContainer.style.marginTop = "0.5rem"; @@ -143,30 +165,101 @@ export default class VDateInputPrompt extends GenericInputPrompt { private updatePreview() { // Don't update if modal is closed if (!this.isOpen) return; - + const input = this.currentInput.trim(); - + // If no input and we have a default, show preview for default if (!input && this.defaultValue) { - this.renderPreview(this.defaultValue); + this.renderPreviewFromInput(this.defaultValue); return; } - + if (!input) { + this.selectedIso = undefined; + this.lastPickerDisplayValue = undefined; + this.syncPickerSelection(); this.setPreviewText(VDateInputPrompt.PREVIEW_PLACEHOLDER, false); return; } - + + if (input.startsWith("@date:")) { + const iso = input.slice(6).trim(); + if (iso) { + this.selectedIso = iso; + this.lastPickerDisplayValue = undefined; + this.syncPickerSelection(iso); + this.renderPreviewFromIso(iso); + return; + } + } + + if ( + this.selectedIso && + this.lastPickerDisplayValue && + input === this.lastPickerDisplayValue + ) { + this.syncPickerSelection(this.selectedIso, false); + this.renderPreviewFromIso(this.selectedIso); + return; + } + // If input matches default value or regular input, render the preview - this.renderPreview(input); + this.renderPreviewFromInput(input); } - private renderPreview(value: string) { + private formatIsoForInput(iso: string): string { + const formatted = formatISODate(iso, this.dateFormat); + if (formatted) return formatted; + return iso.length >= 10 ? iso.slice(0, 10) : iso; + } + + private syncPickerSelection(iso?: string, updateView = true) { + this.datePicker?.setSelectedIso(iso, { updateView }); + } + + private applyPickerSelection(iso: string) { + const displayValue = this.formatIsoForInput(iso); + this.selectedIso = iso; + this.lastPickerDisplayValue = displayValue; + if (this.inputComponent?.inputEl) { + this.inputComponent.inputEl.value = displayValue; + } + this.onInputChanged(displayValue); + this.currentInput = displayValue; + this.syncPickerSelection(iso); + this.renderPreviewFromIso(iso); + } + + private clearPickerSelection() { + if (this.inputComponent?.inputEl) { + this.inputComponent.inputEl.value = ""; + } + this.onInputChanged(""); + this.currentInput = ""; + this.selectedIso = undefined; + this.lastPickerDisplayValue = undefined; + this.syncPickerSelection(); + this.updatePreview(); + } + + private renderPreviewFromIso(iso: string) { + this.setPreviewText(this.formatIsoForInput(iso), false); + } + + private renderPreviewFromInput(value: string) { const parseResult = parseNaturalLanguageDate(value, this.dateFormat); - - if (parseResult.isValid && parseResult.formatted) { - this.setPreviewText(parseResult.formatted, false); + + if (parseResult.isValid && parseResult.isoString) { + this.selectedIso = parseResult.isoString; + this.lastPickerDisplayValue = undefined; + this.syncPickerSelection(parseResult.isoString); + const formatted = + parseResult.formatted ?? this.formatIsoForInput(parseResult.isoString); + this.setPreviewText(formatted, false); } else { + this.selectedIso = undefined; + this.lastPickerDisplayValue = undefined; + this.syncPickerSelection(); const errorMessage = parseResult.error || "Unable to parse date"; this.setPreviewText(errorMessage, true); } @@ -174,7 +267,7 @@ export default class VDateInputPrompt extends GenericInputPrompt { private setPreviewText(text: string, isError: boolean) { this.previewEl.textContent = text; - + if (isError) { this.previewEl.style.color = "var(--text-error)"; } else { @@ -186,18 +279,46 @@ export default class VDateInputPrompt extends GenericInputPrompt { super.onOpen(); } + protected transformInputOnSubmit(input: string): string { + const trimmed = input.trim(); + if (trimmed.startsWith("@date:")) return trimmed; + if ( + this.selectedIso && + this.lastPickerDisplayValue && + trimmed === this.lastPickerDisplayValue + ) { + return `@date:${this.selectedIso}`; + } + if (!trimmed && this.defaultValue) { + const parsed = parseNaturalLanguageDate( + this.defaultValue, + this.dateFormat, + ); + if (parsed.isValid && parsed.isoString) { + return `@date:${parsed.isoString}`; + } + } + if (trimmed) { + const parsed = parseNaturalLanguageDate(trimmed, this.dateFormat); + if (parsed.isValid && parsed.isoString) { + return `@date:${parsed.isoString}`; + } + } + return input; + } + onClose() { // Prevent any pending debounced updates this.isOpen = false; - + // Cancel any pending debounced calls this.updatePreviewDebounced.cancel(); - + // If input is empty and we have a default, use the default if (!this.input.trim() && this.defaultValue) { this.input = this.defaultValue; } - + super.onClose(); } } diff --git a/src/gui/date-picker/datePicker.ts b/src/gui/date-picker/datePicker.ts new file mode 100644 index 00000000..e6ac9883 --- /dev/null +++ b/src/gui/date-picker/datePicker.ts @@ -0,0 +1,252 @@ +export type DatePickerSelectSource = "picker" | "action"; + +export interface DatePickerController { + setSelectedIso( + iso?: string, + options?: { updateView?: boolean }, + ): void; + setViewFromIso(iso: string): void; + destroy(): void; +} + +export interface DatePickerOptions { + container: HTMLElement; + initialIso?: string; + weekStartsOn?: number; + onSelect: (iso: string | null, source: DatePickerSelectSource) => void; +} + +const pad = (value: number) => value.toString().padStart(2, "0"); + +const getWeekStartsOn = (value?: number): number => { + if (typeof value === "number" && value >= 0 && value <= 6) return value; + const moment = window.moment; + const firstDay = moment?.localeData?.()?.firstDayOfWeek?.(); + if (typeof firstDay === "number" && firstDay >= 0 && firstDay <= 6) + return firstDay; + return 0; +}; + +const getWeekdayLabels = (weekStartsOn: number): string[] => { + const formatter = new Intl.DateTimeFormat(undefined, { + weekday: "short", + }); + const base = new Date(2021, 7, 1); + const labels = Array.from({ length: 7 }, (_, i) => { + const date = new Date( + base.getFullYear(), + base.getMonth(), + base.getDate() + i, + ); + return formatter.format(date); + }); + return labels.slice(weekStartsOn).concat(labels.slice(0, weekStartsOn)); +}; + +const getMonthLabel = (year: number, month: number): string => { + const formatter = new Intl.DateTimeFormat(undefined, { + month: "long", + year: "numeric", + }); + return formatter.format(new Date(year, month, 1)); +}; + +const formatDateKey = (year: number, month: number, day: number): string => + `${year}-${pad(month + 1)}-${pad(day)}`; + +const toDateKey = (date: Date): string => + formatDateKey(date.getFullYear(), date.getMonth(), date.getDate()); + +const parseIsoToDate = (iso?: string): Date | null => { + if (!iso) return null; + const dateOnlyMatch = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); + if (dateOnlyMatch) { + const [, year, month, day] = dateOnlyMatch; + return new Date( + Number.parseInt(year, 10), + Number.parseInt(month, 10) - 1, + Number.parseInt(day, 10), + ); + } + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return null; + return parsed; +}; + +const toIsoFromParts = (year: number, month: number, day: number): string => { + return formatDateKey(year, month, day); +}; + +export const createDatePicker = ( + options: DatePickerOptions, +): DatePickerController => { + const weekStartsOn = getWeekStartsOn(options.weekStartsOn); + const weekdayLabels = getWeekdayLabels(weekStartsOn); + + const initialDate = + parseIsoToDate(options.initialIso) ?? new Date(); + let viewYear = initialDate.getFullYear(); + let viewMonth = initialDate.getMonth(); + let selectedIso = options.initialIso; + + const root = options.container.createDiv({ cls: "qa-date-picker" }); + + const header = root.createDiv({ cls: "qa-date-picker__header" }); + const prevBtn = header.createEl("button", { + cls: "qa-date-picker__nav", + text: "‹", + }); + prevBtn.type = "button"; + prevBtn.setAttr("aria-label", "Previous month"); + + const label = header.createDiv({ cls: "qa-date-picker__label" }); + + const nextBtn = header.createEl("button", { + cls: "qa-date-picker__nav", + text: "›", + }); + nextBtn.type = "button"; + nextBtn.setAttr("aria-label", "Next month"); + + const weekdayRow = root.createDiv({ cls: "qa-date-picker__weekdays" }); + weekdayLabels.forEach((day) => { + const cell = weekdayRow.createDiv({ cls: "qa-date-picker__weekday" }); + cell.textContent = day; + }); + + const grid = root.createDiv({ cls: "qa-date-picker__grid" }); + + const actions = root.createDiv({ cls: "qa-date-picker__actions" }); + const todayBtn = actions.createEl("button", { + cls: "qa-date-picker__action", + text: "Today", + }); + todayBtn.type = "button"; + + const clearBtn = actions.createEl("button", { + cls: "qa-date-picker__action", + text: "Clear", + }); + clearBtn.type = "button"; + + const shiftMonth = (delta: number) => { + viewMonth += delta; + if (viewMonth > 11) { + viewMonth = 0; + viewYear += 1; + } else if (viewMonth < 0) { + viewMonth = 11; + viewYear -= 1; + } + render(); + }; + + const selectIso = (iso: string, source: DatePickerSelectSource) => { + selectedIso = iso; + options.onSelect(iso, source); + render(); + }; + + const clearSelection = (source: DatePickerSelectSource) => { + selectedIso = undefined; + options.onSelect(null, source); + render(); + }; + + prevBtn.addEventListener("click", () => shiftMonth(-1)); + nextBtn.addEventListener("click", () => shiftMonth(1)); + + todayBtn.addEventListener("click", () => { + const today = new Date(); + const iso = toIsoFromParts( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ); + viewYear = today.getFullYear(); + viewMonth = today.getMonth(); + selectIso(iso, "action"); + }); + + clearBtn.addEventListener("click", () => clearSelection("action")); + + const render = () => { + label.textContent = getMonthLabel(viewYear, viewMonth); + grid.empty(); + + const firstOfMonth = new Date(viewYear, viewMonth, 1); + const startOffset = + (firstOfMonth.getDay() - weekStartsOn + 7) % 7; + const startDate = new Date(viewYear, viewMonth, 1 - startOffset); + const todayKey = toDateKey(new Date()); + const selectedDate = selectedIso ? parseIsoToDate(selectedIso) : null; + const selectedKey = selectedDate ? toDateKey(selectedDate) : undefined; + + const ariaFormatter = new Intl.DateTimeFormat(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + }); + + for (let i = 0; i < 42; i += 1) { + const date = new Date(startDate); + date.setDate(startDate.getDate() + i); + + const dayKey = toDateKey(date); + const isOutside = date.getMonth() !== viewMonth; + const isToday = dayKey === todayKey; + const isSelected = selectedKey && dayKey === selectedKey; + + const dayBtn = grid.createEl("button", { + cls: "qa-date-picker__day", + text: String(date.getDate()), + }); + dayBtn.type = "button"; + dayBtn.setAttr("aria-label", ariaFormatter.format(date)); + + if (isOutside) dayBtn.addClass("is-outside"); + if (isToday) dayBtn.addClass("is-today"); + if (isSelected) { + dayBtn.addClass("is-selected"); + dayBtn.setAttr("aria-pressed", "true"); + } + + dayBtn.addEventListener("click", () => { + const iso = toIsoFromParts( + date.getFullYear(), + date.getMonth(), + date.getDate(), + ); + selectIso(iso, "picker"); + }); + } + }; + + render(); + + return { + setSelectedIso: (iso, { updateView } = {}) => { + selectedIso = iso; + if (iso && updateView !== false) { + const parsed = parseIsoToDate(iso); + if (parsed) { + viewYear = parsed.getFullYear(); + viewMonth = parsed.getMonth(); + } + } + render(); + }, + setViewFromIso: (iso: string) => { + const parsed = parseIsoToDate(iso); + if (parsed) { + viewYear = parsed.getFullYear(); + viewMonth = parsed.getMonth(); + render(); + } + }, + destroy: () => { + root.remove(); + }, + }; +}; diff --git a/src/preflight/OnePageInputModal.ts b/src/preflight/OnePageInputModal.ts index d02c7f64..b0cee937 100644 --- a/src/preflight/OnePageInputModal.ts +++ b/src/preflight/OnePageInputModal.ts @@ -7,6 +7,7 @@ import { debounce, type App, } from "obsidian"; +import { createDatePicker } from "src/gui/date-picker/datePicker"; import { FieldValueInputSuggest } from "src/gui/suggesters/FieldValueInputSuggest"; import { SuggesterInputSuggest } from "src/gui/suggesters/SuggesterInputSuggest"; import { formatISODate, parseNaturalLanguageDate } from "src/utils/dateParser"; @@ -165,25 +166,44 @@ export class OnePageInputModal extends Modal { this.decorateLabel(req), ); if (req.description) setting.setDesc(req.description); - // Reuse the VDateInputPrompt component behavior by creating an input with preview - const container = setting.controlEl.createDiv(); + const container = setting.controlEl.createDiv({ + cls: "qa-date-input", + }); const input = new TextComponent(container); const placeholder = "Enter a date (e.g., 'today', 'next friday', '2025-12-25')"; - // Friendly display: if initial is @date:ISO, show formatted text instead + let selectedIso: string | undefined; let displayValue = starting; - if (starting?.startsWith("@date:") && req.dateFormat) { - const iso = starting.slice(6); - const formatted = formatISODate(iso, req.dateFormat); - if (formatted) displayValue = formatted; + if (starting?.startsWith("@date:")) { + selectedIso = starting.slice(6); + const formatted = req.dateFormat + ? formatISODate(selectedIso, req.dateFormat) + : undefined; + displayValue = + formatted ?? + (selectedIso.length >= 10 ? selectedIso.slice(0, 10) : selectedIso); } + input.setPlaceholder(placeholder).setValue(displayValue ?? ""); + const pickerContainer = container.createDiv({ + cls: "qa-date-picker-container", + }); + const datePicker = createDatePicker({ + container: pickerContainer, + initialIso: selectedIso, + onSelect: (iso) => { + if (iso) applyPickerSelection(iso); + else clearPickerSelection(); + }, + }); + const preview = container.createDiv(); preview.style.marginTop = "0.25rem"; preview.style.fontSize = "0.9em"; preview.style.fontFamily = "var(--font-monospace)"; + const aliasEntries = getOrderedDateAliases( settingsStore.getState().dateAliases, ); @@ -206,40 +226,101 @@ export class OnePageInputModal extends Modal { aliasList.style.color = "var(--text-muted)"; aliasList.style.fontFamily = "var(--font-monospace)"; } + + const formatIsoForDisplay = (iso: string) => { + if (req.dateFormat) { + const formatted = formatISODate(iso, req.dateFormat); + if (formatted) return formatted; + } + return iso.length >= 10 ? iso.slice(0, 10) : iso; + }; + + const renderPreview = (text: string, isError: boolean) => { + preview.setText(text); + preview.style.color = isError + ? "var(--text-error)" + : "var(--text-normal)"; + }; + + const syncSelection = (iso?: string) => { + datePicker.setSelectedIso(iso); + }; + + const applyPickerSelection = (iso: string) => { + selectedIso = iso; + const display = formatIsoForDisplay(iso); + input.inputEl.value = display; + setValue(req.id, `@date:${iso}`); + renderPreview(display, false); + syncSelection(iso); + }; + + const clearPickerSelection = () => { + input.inputEl.value = ""; + updatePreview(""); + }; + const updatePreview = (val: string) => { const inputVal = (val ?? "").trim(); if (!inputVal && req.defaultValue) { - preview.setText(`Default → ${req.defaultValue}`); - preview.style.color = "var(--text-muted)"; - // Store the default immediately so runtime respects it without re-asking - setValue(req.id, req.defaultValue); + const parsed = parseNaturalLanguageDate( + req.defaultValue, + req.dateFormat, + ); + if (parsed.isValid && parsed.isoString) { + selectedIso = parsed.isoString; + setValue(req.id, `@date:${parsed.isoString}`); + syncSelection(parsed.isoString); + const formatted = + parsed.formatted ?? + formatIsoForDisplay(parsed.isoString); + renderPreview(formatted, false); + return; + } + renderPreview(parsed.error || "Unable to parse date", true); + setValue(req.id, ""); + syncSelection(); return; } if (!inputVal) { - preview.setText("Preview will appear here"); - preview.style.color = "var(--text-muted)"; - // Keep empty to represent intentional empty + selectedIso = undefined; setValue(req.id, ""); + syncSelection(); + renderPreview("Preview will appear here", false); return; } - // Live-parse natural language dates and preview the formatted value + if (inputVal.startsWith("@date:")) { + const iso = inputVal.slice(6).trim(); + if (iso) { + applyPickerSelection(iso); + return; + } + } + const parsed = parseNaturalLanguageDate(inputVal, req.dateFormat); - if (parsed.isValid && parsed.formatted && parsed.isoString) { - preview.setText(parsed.formatted); - preview.style.color = "var(--text-normal)"; - // Store normalized value for execution + if (parsed.isValid && parsed.isoString) { + selectedIso = parsed.isoString; setValue(req.id, `@date:${parsed.isoString}`); + syncSelection(parsed.isoString); + const formatted = + parsed.formatted ?? formatIsoForDisplay(parsed.isoString); + renderPreview(formatted, false); } else { - preview.setText(parsed.error || "Unable to parse date"); - preview.style.color = "var(--text-error)"; - // Keep value empty to avoid committing invalid dates + selectedIso = undefined; setValue(req.id, ""); + syncSelection(); + renderPreview(parsed.error || "Unable to parse date", true); } }; + input.onChange((v) => updatePreview(v)); - // Initialize preview - updatePreview(displayValue ?? ""); + + if (selectedIso) { + applyPickerSelection(selectedIso); + } else { + updatePreview(displayValue ?? ""); + } break; } case "field-suggest": { diff --git a/src/quickAddApi.ts b/src/quickAddApi.ts index 633b93b1..1d278938 100644 --- a/src/quickAddApi.ts +++ b/src/quickAddApi.ts @@ -16,6 +16,7 @@ import GenericSuggester from "./gui/GenericSuggester/genericSuggester"; import GenericWideInputPrompt from "./gui/GenericWideInputPrompt/GenericWideInputPrompt"; import GenericYesNoPrompt from "./gui/GenericYesNoPrompt/GenericYesNoPrompt"; import InputSuggester from "./gui/InputSuggester/inputSuggester"; +import VDateInputPrompt from "./gui/VDateInputPrompt/VDateInputPrompt"; import type { IChoiceExecutor } from "./IChoiceExecutor"; import type QuickAdd from "./main"; import { OnePageInputModal } from "./preflight/OnePageInputModal"; @@ -137,6 +138,16 @@ export class QuickAddApi { inputPrompt: (header: string, placeholder?: string, value?: string) => { return QuickAddApi.inputPrompt(app, header, placeholder, value); }, + datePrompt: ( + header: string, + options?: { + placeholder?: string; + defaultValue?: string; + dateFormat?: string; + }, + ) => { + return QuickAddApi.datePrompt(app, header, options); + }, wideInputPrompt: ( header: string, placeholder?: string, @@ -541,6 +552,37 @@ export class QuickAddApi { } } + public static async datePrompt( + app: App, + header: string, + options?: { + placeholder?: string; + defaultValue?: string; + dateFormat?: string; + }, + ) { + try { + const value = await VDateInputPrompt.Prompt( + app, + header, + options?.placeholder, + options?.defaultValue, + options?.dateFormat, + ); + if (value && value.startsWith("@date:")) { + const iso = value.slice(6); + const formatted = options?.dateFormat + ? formatISODate(iso, options.dateFormat) + : null; + return formatted ?? iso; + } + return value; + } catch (error) { + throwIfPromptCancelled(error); + return undefined; + } + } + public static async wideInputPrompt( app: App, header: string, diff --git a/src/styles.css b/src/styles.css index 824f0ddf..534065f9 100644 --- a/src/styles.css +++ b/src/styles.css @@ -171,6 +171,105 @@ opacity: 0.8; } +.qa-date-input { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.qa-date-picker { + border: 1px solid var(--background-modifier-border); + border-radius: 6px; + padding: 0.5rem; + background-color: var(--background-modifier-form-field); +} + +.qa-date-picker__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.25rem; +} + +.qa-date-picker__nav { + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 4px; + padding: 0.15rem 0.5rem; + cursor: pointer; +} + +.qa-date-picker__nav:hover { + background: var(--background-modifier-hover); +} + +.qa-date-picker__label { + font-weight: 600; +} + +.qa-date-picker__weekdays { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; + font-size: 0.75rem; + color: var(--text-muted); + text-align: center; + margin-bottom: 0.25rem; +} + +.qa-date-picker__grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 2px; +} + +.qa-date-picker__day { + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + padding: 0.35rem 0; + cursor: pointer; +} + +.qa-date-picker__day:hover { + background: var(--background-modifier-hover); +} + +.qa-date-picker__day.is-outside { + opacity: 0.4; +} + +.qa-date-picker__day.is-today { + border-color: var(--interactive-accent); +} + +.qa-date-picker__day.is-selected { + background: var(--interactive-accent); + color: var(--text-on-accent); + border-color: var(--interactive-accent); +} + +.qa-date-picker__actions { + display: flex; + gap: 0.5rem; + margin-top: 0.5rem; + justify-content: flex-end; +} + +.qa-date-picker__action { + background: var(--background-secondary); + border: 1px solid var(--background-modifier-border); + border-radius: 999px; + padding: 0.2rem 0.6rem; + font-size: 0.75rem; + cursor: pointer; +} + +.qa-date-picker__action:hover { + background: var(--background-modifier-hover); +} + /* Priority indicators - subtle left border */ .qa-suggest-exact::before { content: '';