use native date input for task date picker

This commit is contained in:
callumalpass 2026-05-18 19:30:44 +10:00
parent 3342c802b7
commit 9a9f13abe4
4 changed files with 121 additions and 265 deletions

View file

@ -52,6 +52,7 @@ Example:
- (#933) Added non-Markdown vault files such as canvases to project suggestions, using extension-qualified link targets so those files can be selected as task projects. Thanks to @Alvin21Bon for suggesting this.
- (#1475, #725) Added quote/backtick escapes for NLP input, so literal course codes or date words can stay in task titles without being parsed as estimates or dates. Thanks to @RumiaKitinari and @gavingwebb for suggesting this.
- (#1462) Added natural-language date entry to the task date picker, so existing due and scheduled dates can be changed with phrases such as "tomorrow at 3pm" when NLP is enabled. Thanks to @Ruboks-Cube for suggesting this.
- (#1526) Added a native date field to the task date picker, so scheduled and due dates can be changed with the device date picker while keeping optional times visible. Thanks to @jmartinmcfly for suggesting this and @23426356587 for the follow-up feedback.
- (#1603) Added Calendar view toggles for hiding completed or skipped recurring task instances while keeping future outstanding instances visible. Thanks to @wandererovertheseaofpiss for suggesting this.
- (#287, #273, #274) Added linked hover highlighting for Calendar and Agenda events that come from the same task note, so scheduled, due, and recurring entries are easier to connect. Thanks to @girisumit for suggesting this.
- (#280) Added project-specific CSS hooks to task cards and Calendar events, so project-based visual styling can be handled with user snippets. Thanks to @girisumit for suggesting this.

View file

@ -24,16 +24,6 @@ export interface ParsedDateTimeSelection {
time: string | null;
}
export interface CalendarGridDay {
date: string;
day: number;
isCurrentMonth: boolean;
isSelected: boolean;
isToday: boolean;
}
const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
function pad(value: number): string {
return String(value).padStart(2, "0");
}
@ -42,32 +32,6 @@ export function formatCalendarDate(year: number, monthIndex: number, day: number
return `${year}-${pad(monthIndex + 1)}-${pad(day)}`;
}
export function parseCalendarDate(value: string | null | undefined): Date | null {
if (!value) return null;
const match = DATE_PATTERN.exec(value);
if (!match) return null;
const year = Number(match[1]);
const monthIndex = Number(match[2]) - 1;
const day = Number(match[3]);
const date = new Date(year, monthIndex, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== monthIndex ||
date.getDate() !== day
) {
return null;
}
return date;
}
export function getInitialDisplayMonth(currentDate: string | null | undefined): Date {
const parsed = parseCalendarDate(currentDate) ?? new Date();
return new Date(parsed.getFullYear(), parsed.getMonth(), 1);
}
export function getParsedDateTimeSelection(
parsed: Pick<ParsedTaskData, "dueDate" | "dueTime" | "scheduledDate" | "scheduledTime">,
dateRole?: "due" | "scheduled"
@ -103,39 +67,6 @@ export function parseNaturalLanguageDateTime(
return getParsedDateTimeSelection(parser.parseInput(trimmed), dateRole);
}
export function buildCalendarGrid(
displayMonth: Date,
selectedDate: string | null | undefined,
today = new Date()
): CalendarGridDay[] {
const monthStart = new Date(displayMonth.getFullYear(), displayMonth.getMonth(), 1);
const gridStart = new Date(monthStart);
gridStart.setDate(monthStart.getDate() - monthStart.getDay());
const todayValue = formatCalendarDate(today.getFullYear(), today.getMonth(), today.getDate());
const days: CalendarGridDay[] = [];
for (let index = 0; index < 42; index += 1) {
const dayDate = new Date(gridStart);
dayDate.setDate(gridStart.getDate() + index);
const dateValue = formatCalendarDate(
dayDate.getFullYear(),
dayDate.getMonth(),
dayDate.getDate()
);
days.push({
date: dateValue,
day: dayDate.getDate(),
isCurrentMonth: dayDate.getMonth() === displayMonth.getMonth(),
isSelected: dateValue === selectedDate,
isToday: dateValue === todayValue,
});
}
return days;
}
function addDays(date: Date, days: number): Date {
const result = new Date(date);
result.setDate(result.getDate() + days);
@ -155,18 +86,15 @@ function nextMonday(from: Date): Date {
export class DateTimePickerModal extends Modal {
private readonly options: DateTimePickerOptions;
private selectedDate: string | null;
private displayMonth: Date;
private naturalLanguageInput: HTMLInputElement | null = null;
private dateInput: HTMLInputElement | null = null;
private timeInput: HTMLInputElement | null = null;
private monthLabelEl: HTMLElement | null = null;
private calendarGridEl: HTMLElement | null = null;
private selectButtonEl: HTMLButtonElement | null = null;
constructor(app: App, options: DateTimePickerOptions) {
super(app);
this.options = options;
this.selectedDate = options.currentDate ?? null;
this.displayMonth = getInitialDisplayMonth(options.currentDate);
}
onOpen(): void {
@ -183,17 +111,13 @@ export class DateTimePickerModal extends Modal {
this.renderQuickActions(contentEl);
this.renderNaturalLanguageInput(contentEl);
this.renderCalendar(contentEl);
this.renderDateInput(contentEl);
this.renderTimeInput(contentEl);
this.renderActions(contentEl);
this.updateSelectButtonState();
window.setTimeout(() => {
this.calendarGridEl
?.querySelector<HTMLButtonElement>(
".date-time-picker-modal__day.is-selected, .date-time-picker-modal__day.is-today"
)
?.focus();
this.dateInput?.focus();
}, 100);
}
@ -254,71 +178,55 @@ export class DateTimePickerModal extends Modal {
applyButton.addEventListener("click", () => this.applyNaturalLanguageInput());
}
private renderCalendar(container: HTMLElement): void {
const calendar = container.createDiv({ cls: "date-time-picker-modal__calendar" });
const header = calendar.createDiv({ cls: "date-time-picker-modal__calendar-header" });
const previousButton = header.createEl("button", {
cls: "clickable-icon date-time-picker-modal__nav-button",
attr: { type: "button", "aria-label": "Previous month", title: "Previous month" },
});
setIcon(previousButton, "chevron-left");
previousButton.addEventListener("click", () => this.changeMonth(-1));
this.monthLabelEl = header.createDiv({ cls: "date-time-picker-modal__month-label" });
const nextButton = header.createEl("button", {
cls: "clickable-icon date-time-picker-modal__nav-button",
attr: { type: "button", "aria-label": "Next month", title: "Next month" },
});
setIcon(nextButton, "chevron-right");
nextButton.addEventListener("click", () => this.changeMonth(1));
const weekdays = calendar.createDiv({ cls: "date-time-picker-modal__weekdays" });
for (const day of ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]) {
weekdays.createDiv({ text: day, cls: "date-time-picker-modal__weekday" });
}
this.calendarGridEl = calendar.createDiv({ cls: "date-time-picker-modal__grid" });
this.renderCalendarGrid();
}
private renderCalendarGrid(): void {
if (!this.calendarGridEl || !this.monthLabelEl) return;
this.monthLabelEl.textContent = this.displayMonth.toLocaleDateString(undefined, {
month: "long",
year: "numeric",
private renderDateInput(container: HTMLElement): void {
const field = container.createDiv({ cls: "date-time-picker-modal__date-field" });
field.createEl("label", {
text: "Date",
cls: "date-time-picker-modal__field-label",
attr: { for: "tasknotes-date-time-picker-date" },
});
this.calendarGridEl.empty();
const days = buildCalendarGrid(this.displayMonth, this.selectedDate);
for (const day of days) {
const button = this.calendarGridEl.createEl("button", {
text: String(day.day),
cls: [
"date-time-picker-modal__day",
day.isCurrentMonth ? "is-current-month" : "is-outside-month",
day.isSelected ? "is-selected" : "",
day.isToday ? "is-today" : "",
]
.filter(Boolean)
.join(" "),
attr: {
type: "button",
"aria-label": day.date,
"aria-pressed": day.isSelected ? "true" : "false",
},
});
button.addEventListener("click", () => this.selectDate(day.date));
}
const control = field.createDiv({ cls: "date-time-picker-modal__native-date-control" });
this.dateInput = control.createEl("input", {
cls: "date-time-picker-modal__date-input",
attr: {
id: "tasknotes-date-time-picker-date",
type: "date",
value: this.selectedDate ?? "",
"aria-label": "Date",
},
});
this.dateInput.addEventListener("change", () => {
if (!this.dateInput?.value) return;
this.selectDate(this.dateInput.value);
});
this.dateInput.addEventListener("keydown", (event) => {
if (event.key === "Enter" && this.dateInput?.value) {
event.preventDefault();
this.selectDate(this.dateInput.value);
}
});
const pickerButton = control.createEl("button", {
cls: "clickable-icon date-time-picker-modal__native-date-button",
attr: {
type: "button",
"aria-label": "Open native date picker",
title: "Open native date picker",
},
});
setIcon(pickerButton, "calendar-days");
pickerButton.addEventListener("click", () => {
this.dateInput?.showPicker?.();
this.dateInput?.focus();
});
}
private renderTimeInput(container: HTMLElement): void {
const field = container.createDiv({ cls: "date-time-picker-modal__time-field" });
field.createEl("label", {
text: "Time (optional)",
cls: "date-time-picker-modal__time-label",
cls: "date-time-picker-modal__field-label",
attr: { for: "tasknotes-date-time-picker-time" },
});
@ -368,15 +276,6 @@ export class DateTimePickerModal extends Modal {
this.selectButtonEl.addEventListener("click", () => this.confirmSelectedDate());
}
private changeMonth(delta: number): void {
this.displayMonth = new Date(
this.displayMonth.getFullYear(),
this.displayMonth.getMonth() + delta,
1
);
this.renderCalendarGrid();
}
private selectDate(date: string): void {
this.selectedDate = date;
this.confirmSelectedDate();

View file

@ -2,7 +2,7 @@
DATE PICKER MODAL - Enhanced Input Styling
===================================================================== */
/* Calendar-first date/time picker used by task date actions */
/* Native date/time picker used by task date actions */
.tasknotes-plugin.date-time-picker-modal {
min-width: min(360px, calc(100vw - 32px));
}
@ -44,88 +44,12 @@
justify-content: center;
}
.tasknotes-plugin .date-time-picker-modal__calendar {
margin-bottom: var(--size-4-3);
}
.tasknotes-plugin .date-time-picker-modal__calendar-header {
display: grid;
grid-template-columns: 32px 1fr 32px;
align-items: center;
gap: var(--size-4-2);
margin-bottom: var(--size-4-2);
}
.tasknotes-plugin .date-time-picker-modal__nav-button {
width: 32px;
height: 32px;
justify-content: center;
}
.tasknotes-plugin .date-time-picker-modal__month-label {
text-align: center;
font-weight: 600;
color: var(--text-normal);
}
.tasknotes-plugin .date-time-picker-modal__weekdays,
.tasknotes-plugin .date-time-picker-modal__grid {
display: grid;
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 2px;
}
.tasknotes-plugin .date-time-picker-modal__weekdays {
margin-bottom: var(--size-2-1);
}
.tasknotes-plugin .date-time-picker-modal__weekday {
height: 24px;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
font-size: var(--font-ui-smaller);
font-weight: 500;
}
.tasknotes-plugin .date-time-picker-modal__day {
aspect-ratio: 1;
min-width: 0;
border: 0;
border-radius: var(--radius-s);
background: transparent;
color: var(--text-normal);
font-size: var(--font-ui-small);
cursor: var(--cursor-link, pointer);
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
}
.tasknotes-plugin .date-time-picker-modal__day:hover,
.tasknotes-plugin .date-time-picker-modal__day:focus-visible {
background: var(--background-modifier-hover);
outline: none;
}
.tasknotes-plugin .date-time-picker-modal__day.is-outside-month {
color: var(--text-faint);
}
.tasknotes-plugin .date-time-picker-modal__day.is-today {
box-shadow: inset 0 0 0 1px var(--interactive-accent);
}
.tasknotes-plugin .date-time-picker-modal__day.is-selected {
background: var(--interactive-accent);
color: var(--text-on-accent);
}
.tasknotes-plugin .date-time-picker-modal__date-field,
.tasknotes-plugin .date-time-picker-modal__time-field {
margin-bottom: var(--size-4-3);
}
.tasknotes-plugin .date-time-picker-modal__time-label {
.tasknotes-plugin .date-time-picker-modal__field-label {
display: block;
margin-bottom: var(--size-2-1);
color: var(--text-muted);
@ -133,8 +57,38 @@
font-weight: 500;
}
.tasknotes-plugin .date-time-picker-modal__native-date-control {
display: grid;
grid-template-columns: minmax(0, 1fr) 32px;
align-items: center;
gap: var(--size-4-2);
}
.tasknotes-plugin .date-time-picker-modal__date-input,
.tasknotes-plugin .date-time-picker-modal__time-input {
width: 100%;
min-width: 0;
height: 36px;
}
.tasknotes-plugin .date-time-picker-modal__date-input {
color-scheme: light dark;
}
.tasknotes-plugin .date-time-picker-modal__date-input::-webkit-calendar-picker-indicator {
cursor: var(--cursor-link, pointer);
opacity: 0.72;
}
.tasknotes-plugin .date-time-picker-modal__date-input:hover::-webkit-calendar-picker-indicator,
.tasknotes-plugin .date-time-picker-modal__date-input:focus::-webkit-calendar-picker-indicator {
opacity: 1;
}
.tasknotes-plugin .date-time-picker-modal__native-date-button {
width: 32px;
height: 32px;
justify-content: center;
}
.tasknotes-plugin .date-time-picker-modal__actions {

View file

@ -1,26 +1,9 @@
import { DateTimePickerModal, buildCalendarGrid } from "../../../src/modals/DateTimePickerModal";
import { DateTimePickerModal } from "../../../src/modals/DateTimePickerModal";
jest.mock("obsidian");
describe("Issue #1526: calendar-first task date picker", () => {
it("builds a stable 6-week month grid with selected and today states", () => {
const days = buildCalendarGrid(
new Date(2026, 0, 1),
"2026-01-15",
new Date(2026, 0, 15)
);
expect(days).toHaveLength(42);
expect(days[0].date).toBe("2025-12-28");
expect(days[41].date).toBe("2026-02-07");
expect(days.find((day) => day.date === "2026-01-15")).toMatchObject({
isCurrentMonth: true,
isSelected: true,
isToday: true,
});
});
it("saves immediately when a calendar day is clicked", () => {
describe("Issue #1526: native task date picker", () => {
it("uses a native date input initialized with the current date", () => {
const onSelect = jest.fn();
const modal = new DateTimePickerModal({} as any, {
currentDate: "2026-01-15",
@ -30,19 +13,40 @@ describe("Issue #1526: calendar-first task date picker", () => {
modal.open();
const targetDay = Array.from(
modal.contentEl.querySelectorAll<HTMLButtonElement>(
".date-time-picker-modal__day"
)
).find((button) => button.getAttribute("aria-label") === "2026-01-20");
const dateInput = modal.contentEl.querySelector<HTMLInputElement>(
'input[type="date"].date-time-picker-modal__date-input'
);
const timeInput = modal.contentEl.querySelector<HTMLInputElement>(
'input[type="time"].date-time-picker-modal__time-input'
);
expect(targetDay).toBeTruthy();
targetDay?.click();
expect(dateInput).toBeTruthy();
expect(dateInput?.value).toBe("2026-01-15");
expect(timeInput?.value).toBe("09:30");
});
it("saves immediately when the native date input changes", () => {
const onSelect = jest.fn();
const modal = new DateTimePickerModal({} as any, {
currentDate: "2026-01-15",
currentTime: "09:30",
onSelect,
});
modal.open();
const dateInput = modal.contentEl.querySelector<HTMLInputElement>(
'input[type="date"].date-time-picker-modal__date-input'
);
expect(dateInput).toBeTruthy();
dateInput!.value = "2026-01-20";
dateInput!.dispatchEvent(new Event("change", { bubbles: true }));
expect(onSelect).toHaveBeenCalledWith("2026-01-20", "09:30");
});
it("updates the month grid without changing the selected date", () => {
it("keeps the selected date available for explicit selection after editing time", () => {
const onSelect = jest.fn();
const modal = new DateTimePickerModal({} as any, {
currentDate: "2026-01-15",
@ -51,21 +55,19 @@ describe("Issue #1526: calendar-first task date picker", () => {
modal.open();
const nextMonthButton = modal.contentEl.querySelector<HTMLButtonElement>(
'[aria-label="Next month"]'
const timeInput = modal.contentEl.querySelector<HTMLInputElement>(
'input[type="time"].date-time-picker-modal__time-input'
);
const selectButton = modal.contentEl.querySelector<HTMLButtonElement>(
".date-time-picker-modal__action-button.mod-cta"
);
nextMonthButton?.click();
const februaryDay = Array.from(
modal.contentEl.querySelectorAll<HTMLButtonElement>(
".date-time-picker-modal__day"
)
).find((button) => button.getAttribute("aria-label") === "2026-02-10");
expect(timeInput).toBeTruthy();
expect(selectButton).toBeTruthy();
expect(februaryDay).toBeTruthy();
expect(
modal.contentEl.querySelector(".date-time-picker-modal__day.is-selected")?.textContent
).not.toBe("10");
expect(onSelect).not.toHaveBeenCalled();
timeInput!.value = "14:15";
selectButton!.click();
expect(onSelect).toHaveBeenCalledWith("2026-01-15", "14:15");
});
});