mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
use native date input for task date picker
This commit is contained in:
parent
3342c802b7
commit
9a9f13abe4
4 changed files with 121 additions and 265 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue