diff --git a/docs/releases/unreleased.md b/docs/releases/unreleased.md index b5a6da2f..04b125b9 100644 --- a/docs/releases/unreleased.md +++ b/docs/releases/unreleased.md @@ -103,6 +103,7 @@ Example: - ([#1326](https://github.com/callumalpass/tasknotes/issues/1326)) Treated empty task frontmatter values as missing, so empty titles fall back to the filename and blank date, priority, and recurrence fields are ignored. Thanks to @craziedde for suggesting this. - ([#1316](https://github.com/callumalpass/tasknotes/issues/1316)) Stopped stale Workspace state from overriding the configured Calendar view mode, so switching Workspaces no longer forces an old Day/Week/List choice over the view default. Thanks to @kmaustral for reporting this and following up. - ([#1302](https://github.com/callumalpass/tasknotes/issues/1302)) Read task frontmatter directly from the task file when Obsidian has not indexed its metadata yet, so visible tasks can still be opened, updated, and selected instead of reporting "Task not found". Thanks to @WorldTeacher for reporting this and @Steven-AA for confirming it. +- ([#1297](https://github.com/callumalpass/tasknotes/issues/1297)) Made the Create or open task footer tappable, so mobile users can create the typed task without needing the Shift+Enter shortcut. Thanks to @slipstyle for reporting this. - Made TaskNotes Kanban drags show a lightweight held-card preview, so the dragged card remains easy to track while the source column opens the drop slot. - ([#1423](https://github.com/callumalpass/tasknotes/issues/1423)) Refreshed TaskNotes Bases views when subtasks are deleted, so expanded project cards drop removed subtasks and stop showing project controls when the last subtask is gone. Thanks to @normenmueller for reporting this. - ([#1419](https://github.com/callumalpass/tasknotes/issues/1419)) Kept custom user field name, key, and default-value edits in Task Properties settings when closing settings without first blurring the field. Thanks to @s33a for reporting this and @kacoroski for the follow-up. diff --git a/src/modals/TaskSelectorWithCreateModal.ts b/src/modals/TaskSelectorWithCreateModal.ts index 44818b5f..c012d67d 100644 --- a/src/modals/TaskSelectorWithCreateModal.ts +++ b/src/modals/TaskSelectorWithCreateModal.ts @@ -76,6 +76,7 @@ export class TaskSelectorWithCreateModal extends SuggestModal { private createFooterEl: HTMLElement | null = null; private currentQuery = ""; private resultHandled = false; + private isCreatingTask = false; constructor( app: App, @@ -146,7 +147,12 @@ export class TaskSelectorWithCreateModal extends SuggestModal { // We want to append our footer inside the modalEl, after .prompt const modalContentEl = this.modalEl.querySelector(".prompt")?.parentElement || this.modalEl; - this.createFooterEl = createDiv({ cls: "task-selector-create-footer" }); + this.createFooterEl = modalContentEl.createDiv({ cls: "task-selector-create-footer" }); + this.createFooterEl.setAttribute("role", "button"); + this.createFooterEl.setAttribute("aria-hidden", "true"); + this.createFooterEl.tabIndex = -1; + this.createFooterEl.addEventListener("click", this.handleCreateFooterActivation); + this.createFooterEl.addEventListener("keydown", this.handleCreateFooterKeydown); this.createFooterEl.classList.remove( "tn-static-display-block-2a1b75c9", "tn-static-display-flex-4d51fc62", @@ -158,9 +164,33 @@ export class TaskSelectorWithCreateModal extends SuggestModal { "tn-static-min-height-800px-997b4c8c" ); this.createFooterEl.classList.add("tn-static-display-none-6b99de8b"); - modalContentEl.appendChild(this.createFooterEl); + + if (this.currentQuery) { + this.updateCreateFooter(this.currentQuery); + } } + private handleCreateFooterActivation = (event: MouseEvent | KeyboardEvent): void => { + if ( + !this.createFooterEl || + this.createFooterEl.classList.contains("tn-static-display-none-6b99de8b") + ) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + void this.createNewTask(); + }; + + private handleCreateFooterKeydown = (event: KeyboardEvent): void => { + if (event.key !== "Enter" && event.key !== " ") { + return; + } + + this.handleCreateFooterActivation(event); + }; + private handleInputChange = (): void => { const query = this.inputEl.value.trim(); this.currentQuery = query; @@ -182,6 +212,9 @@ export class TaskSelectorWithCreateModal extends SuggestModal { "tn-static-min-height-800px-997b4c8c" ); this.createFooterEl.classList.add("tn-static-display-none-6b99de8b"); + this.createFooterEl.setAttribute("aria-hidden", "true"); + this.createFooterEl.removeAttribute("aria-label"); + this.createFooterEl.tabIndex = -1; this.createFooterEl.empty(); return; } @@ -201,6 +234,12 @@ export class TaskSelectorWithCreateModal extends SuggestModal { "tn-static-min-height-800px-997b4c8c" ); this.createFooterEl.classList.add("tn-static-display-flex-75816cae"); + this.createFooterEl.setAttribute("aria-hidden", "false"); + this.createFooterEl.setAttribute( + "aria-label", + `${this.translate("modals.taskSelectorWithCreate.footer.createLabel").trim()} ${parsed.title}`.trim() + ); + this.createFooterEl.tabIndex = 0; // Icon const iconDiv = this.createFooterEl.createDiv({ @@ -263,6 +302,9 @@ export class TaskSelectorWithCreateModal extends SuggestModal { "tn-static-min-height-800px-997b4c8c" ); this.createFooterEl.classList.add("tn-static-display-none-6b99de8b"); + this.createFooterEl.setAttribute("aria-hidden", "true"); + this.createFooterEl.removeAttribute("aria-label"); + this.createFooterEl.tabIndex = -1; this.createFooterEl.empty(); } } @@ -367,12 +409,18 @@ export class TaskSelectorWithCreateModal extends SuggestModal { } private async createNewTask(): Promise { + if (this.isCreatingTask) { + return; + } + const query = this.inputEl.value.trim(); if (!query) { new Notice(this.translate("modals.taskSelectorWithCreate.notices.emptyQuery")); return; } + this.isCreatingTask = true; + try { // Parse the query using NLP const parsed = this.nlParser.parseInput(query); @@ -402,6 +450,8 @@ export class TaskSelectorWithCreateModal extends SuggestModal { console.error("Failed to create task:", error); const message = error instanceof Error ? error.message : String(error); new Notice(this.translate("modals.taskCreation.notices.failure", { message })); + } finally { + this.isCreatingTask = false; } } @@ -478,6 +528,8 @@ export class TaskSelectorWithCreateModal extends SuggestModal { // Clean up footer element if (this.createFooterEl) { + this.createFooterEl.removeEventListener("click", this.handleCreateFooterActivation); + this.createFooterEl.removeEventListener("keydown", this.handleCreateFooterKeydown); this.createFooterEl.remove(); this.createFooterEl = null; } diff --git a/styles/task-selector-with-create-modal.css b/styles/task-selector-with-create-modal.css index d56d731f..2c41c19e 100644 --- a/styles/task-selector-with-create-modal.css +++ b/styles/task-selector-with-create-modal.css @@ -47,6 +47,22 @@ border-top: 1px solid var(--background-modifier-border); margin-top: auto; min-height: 60px; + cursor: pointer; + transition: background-color 120ms ease; +} + +.task-selector-create-footer:hover, +.task-selector-create-footer:focus-visible { + background: var(--background-modifier-hover); +} + +.task-selector-create-footer:focus-visible { + outline: 2px solid var(--background-modifier-border-focus); + outline-offset: -2px; +} + +.task-selector-create-footer:active { + background: var(--background-modifier-active-hover); } .task-selector-create-footer__icon { diff --git a/tests/unit/issues/issue-1297-task-selector-create-footer.test.ts b/tests/unit/issues/issue-1297-task-selector-create-footer.test.ts new file mode 100644 index 00000000..a8b1d206 --- /dev/null +++ b/tests/unit/issues/issue-1297-task-selector-create-footer.test.ts @@ -0,0 +1,100 @@ +import { TaskSelectorWithCreateModal } from "../../../src/modals/TaskSelectorWithCreateModal"; +import { NLPSuggest } from "../../../src/modals/taskCreationSuggest"; +import { MockObsidian } from "../../__mocks__/obsidian"; +import type { App } from "obsidian"; + +jest.mock("obsidian"); + +const mockClose = jest.fn(); + +jest.mock("../../../src/modals/taskCreationSuggest", () => ({ + NLPSuggest: jest.fn().mockImplementation(() => ({ + close: mockClose, + })), +})); + +const createMockApp = (mockApp: unknown): App => mockApp as App; + +describe("Issue #1297: Create or open task footer activation", () => { + let mockApp: App; + let mockPlugin: any; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + MockObsidian.reset(); + + mockApp = createMockApp(MockObsidian.createMockApp()); + mockPlugin = { + app: mockApp, + settings: { + customStatuses: [], + customPriorities: [], + defaultTaskStatus: "open", + defaultTaskPriority: "normal", + nlpDefaultToScheduled: true, + nlpLanguage: "en", + nlpTriggers: { enabled: true, triggers: [] }, + userFields: [], + calendarViewSettings: { locale: "en" }, + }, + i18n: { + translate: jest.fn((key: string) => key), + }, + statusManager: { + isCompletedStatus: jest.fn(() => false), + getStatusConfig: jest.fn(() => null), + }, + taskService: { + createTask: jest.fn().mockResolvedValue({ + taskInfo: { + title: "Buy milk", + status: "open", + priority: "normal", + path: "Tasks/Buy milk.md", + }, + }), + }, + }; + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("creates the typed task when the footer is tapped", async () => { + const onResult = jest.fn(); + const modal = new TaskSelectorWithCreateModal(mockApp, mockPlugin, [], { + onResult, + }); + + modal.onOpen(); + jest.runOnlyPendingTimers(); + + expect(NLPSuggest).toHaveBeenCalledWith(mockApp, modal.inputEl, mockPlugin); + + modal.inputEl.value = "Buy milk"; + modal.inputEl.dispatchEvent(new Event("input")); + + const footer = modal.modalEl.querySelector(".task-selector-create-footer") as HTMLElement; + expect(footer).not.toBeNull(); + expect(footer.getAttribute("role")).toBe("button"); + expect(footer.tabIndex).toBe(0); + + footer.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + + expect(mockPlugin.taskService.createTask).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Buy milk", + status: "open", + priority: "normal", + }) + ); + expect(onResult).toHaveBeenCalledWith({ + type: "created", + task: expect.objectContaining({ title: "Buy milk" }), + }); + expect(mockClose).toHaveBeenCalledTimes(1); + }); +});