From 54acc22f6845e034eaef725c2f786d5dd4c2174c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 16:48:12 +0000 Subject: [PATCH 1/2] feat(preflight): add wiki-link autocomplete and peek to one-page inputs One-page text and textarea fields now complete [[ and # the same way sequential prompts do. Peek at note hides the whole form and inserts an editor selection into the last focused free-text field. Closes #1702 Co-authored-by: Christian Bager Bach Houmann --- .../docs/docs/Advanced/onePageInputs.md | 5 + .../content/docs/docs/ControllingPrompts.md | 10 +- docs/src/content/docs/docs/SuggesterSystem.md | 6 +- ...utModal.audit-preflight-suggesters.test.ts | 22 ++ .../OnePageInputModal.linkSuggesters.test.ts | 194 ++++++++++++++++ src/preflight/OnePageInputModal.peek.test.ts | 217 ++++++++++++++++++ src/preflight/OnePageInputModal.test.ts | 22 ++ src/preflight/OnePageInputModal.ts | 118 ++++++++-- 8 files changed, 570 insertions(+), 24 deletions(-) create mode 100644 src/preflight/OnePageInputModal.linkSuggesters.test.ts create mode 100644 src/preflight/OnePageInputModal.peek.test.ts diff --git a/docs/src/content/docs/docs/Advanced/onePageInputs.md b/docs/src/content/docs/docs/Advanced/onePageInputs.md index 37237f571..6024389a5 100644 --- a/docs/src/content/docs/docs/Advanced/onePageInputs.md +++ b/docs/src/content/docs/docs/Advanced/onePageInputs.md @@ -56,6 +56,11 @@ QuickAdd scans the choice for placeholders and turns each one into a field: - The capture target file, when you are capturing to a folder or a tag. - Inputs declared by a user script inside a macro, if the script provides them. +Text and textarea fields support `[[` file links and `#` tags. **Peek at note** +hides the whole form while you read or select text in the open note. **Insert +selection** returns text to the last text field you focused, or the first text +field if you have not focused one. + ### How dates behave in the form {#date-ux} - Date fields accept natural language, like `today` or `next friday`. diff --git a/docs/src/content/docs/docs/ControllingPrompts.md b/docs/src/content/docs/docs/ControllingPrompts.md index 3368ffdb1..0e345e9f1 100644 --- a/docs/src/content/docs/docs/ControllingPrompts.md +++ b/docs/src/content/docs/docs/ControllingPrompts.md @@ -90,19 +90,19 @@ Skipping is an answer; pressing **Esc** still cancels the whole choice. If the s ## Peek at the note {#peek} -Single-line and multi-line text prompts can get out of the way so you can read or select text in the open note, then come back to the same draft. +Single-line and multi-line text prompts, plus the one-page input form, can get out of the way so you can read or select text in the open note, then come back to the same draft. -- **Peek at note** on the prompt, or `Ctrl/Cmd+Shift+E` on desktop, hides the prompt without cancelling the run. Everything in the prompt survives the peek: the draft, its undo history, a paste still saving, even the text you had selected in the field. -- A chip stays on screen: **Insert selection** replaces the field's selection (or drops in at the caret) and returns, **Return** comes back as-is, and **Cancel** aborts the run. On desktop, `Ctrl/Cmd+Shift+E` also returns. Peek does not bind `Esc` in the editor, so Vim users can still leave insert mode, and your other Obsidian hotkeys keep working while the chip is up. +- **Peek at note** on the prompt, or `Ctrl/Cmd+Shift+E` on desktop, hides the prompt without cancelling the run. On the one-page form, it hides the whole form. Everything in the prompt survives the peek: the draft, its undo history, a paste still saving, even the text you had selected in the field. +- A chip stays on screen: **Insert selection** replaces the field's selection (or drops in at the caret) and returns, **Return** comes back as-is, and **Cancel** aborts the run. On the one-page form, **Insert selection** uses the last text field you focused, or the first text field if you have not focused one. On desktop, `Ctrl/Cmd+Shift+E` also returns. Peek does not bind `Esc` in the editor, so Vim users can still leave insert mode, and your other Obsidian hotkeys keep working while the chip is up. - The command **QuickAdd: Return to prompt** is available while a peek is open. On a phone the chip is a one-line bar under the note header, so the bottom nav and home indicator stay free, and prompt actions stay on one row (Peek / Cancel / Ok) so the keyboard does not cover Cancel. -- Peek appears on text prompts opened during a choice run or from the [API](/docs/QuickAddAPI/), not on QuickAdd's own settings and builder prompts. Only one run can be parked: peeking a second prompt cancels the first, and returning waits until any other open prompt is closed. +- Peek appears on text prompts opened during a choice run or from the [API](/docs/QuickAddAPI/), and on the one-page input form. It does not appear on QuickAdd's own settings and builder prompts. Only one run can be parked: peeking a second prompt cancels the first, and returning waits until any other open prompt is closed. ## Autocomplete while you type {#autocomplete-inside-prompts} Inside a prompt, `#` searches your vault's tags and `[[` searches your files (headings, blocks, and relative paths work too). See [Suggester System](/docs/SuggesterSystem/) for all triggers and keys. -These triggers work in the single-line and multi-line prompts. The one-page form's plain text fields do not offer them, though its field and pick-list inputs have their own inline suggestions. +These triggers work in single-line and multi-line prompts, and in text and textarea fields on the one-page form. Field and pick-list widgets keep their own inline suggestions. ## One form instead of many prompts {#one-form-instead-of-many-prompts} diff --git a/docs/src/content/docs/docs/SuggesterSystem.md b/docs/src/content/docs/docs/SuggesterSystem.md index 2cccf4a44..e2c949233 100644 --- a/docs/src/content/docs/docs/SuggesterSystem.md +++ b/docs/src/content/docs/docs/SuggesterSystem.md @@ -24,9 +24,9 @@ Type one of these triggers anywhere in a prompt: :::note[Where the triggers work] The `#` and `[[` triggers work in QuickAdd's single-line and multi-line input -prompts. The [one-page input form](/docs/ControllingPrompts/#one-form-instead-of-many-prompts)'s -text fields don't offer them; its field and pick-list inputs have their own -inline suggestions instead. +prompts, and in text and textarea fields on the +[one-page input form](/docs/ControllingPrompts/#one-form-instead-of-many-prompts). +Field and pick-list widgets keep their own inline suggestions. ::: ### Search your tags: `#` {#tag-search} diff --git a/src/preflight/OnePageInputModal.audit-preflight-suggesters.test.ts b/src/preflight/OnePageInputModal.audit-preflight-suggesters.test.ts index dc2c17f0f..b15280750 100644 --- a/src/preflight/OnePageInputModal.audit-preflight-suggesters.test.ts +++ b/src/preflight/OnePageInputModal.audit-preflight-suggesters.test.ts @@ -172,6 +172,7 @@ vi.mock("obsidian", () => { } return { + ButtonComponent, DropdownComponent, Modal, Notice, @@ -183,6 +184,27 @@ vi.mock("obsidian", () => { }; }); +vi.mock("src/gui/suggesters/fileSuggester", () => ({ + FileSuggester: class { + destroy = vi.fn(); + }, +})); + +vi.mock("src/gui/suggesters/tagSuggester", () => ({ + TagSuggester: class { + destroy = vi.fn(); + }, +})); + +vi.mock("src/gui/promptPeek/stylePeekButton", () => ({ + applyCompactPromptChrome: vi.fn(), + stylePeekButton: (button: T): T => { + button.buttonEl.textContent = "Peek at note"; + button.buttonEl.classList.add("qa-peek-button"); + return button; + }, +})); + vi.mock("src/gui/date-picker/datePicker", () => ({ createDatePicker: () => ({ setSelectedIso: vi.fn() }), })); diff --git a/src/preflight/OnePageInputModal.linkSuggesters.test.ts b/src/preflight/OnePageInputModal.linkSuggesters.test.ts new file mode 100644 index 000000000..bafc42e43 --- /dev/null +++ b/src/preflight/OnePageInputModal.linkSuggesters.test.ts @@ -0,0 +1,194 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { App } from "obsidian"; +import type QuickAdd from "../main"; +import { setQuickAddInstance } from "../quickAddInstance"; +import type { FieldRequirement } from "./RequirementCollector"; +import { OnePageInputModal } from "./OnePageInputModal"; + +const { fileSuggesters, tagSuggesters } = vi.hoisted(() => ({ + fileSuggesters: [] as Array<{ + inputEl: HTMLInputElement | HTMLTextAreaElement; + options: unknown; + destroy: ReturnType; + }>, + tagSuggesters: [] as Array<{ + inputEl: HTMLInputElement | HTMLTextAreaElement; + destroy: ReturnType; + }>, +})); + +vi.mock("src/gui/suggesters/fileSuggester", () => ({ + FileSuggester: class { + destroy = vi.fn(); + + constructor( + _app: App, + inputEl: HTMLInputElement | HTMLTextAreaElement, + options?: unknown, + ) { + fileSuggesters.push({ inputEl, options, destroy: this.destroy }); + } + }, +})); + +vi.mock("src/gui/suggesters/tagSuggester", () => ({ + TagSuggester: class { + destroy = vi.fn(); + + constructor( + _app: App, + inputEl: HTMLInputElement | HTMLTextAreaElement, + ) { + tagSuggesters.push({ inputEl, destroy: this.destroy }); + } + }, +})); + +vi.mock("src/gui/suggesters/FieldValueInputSuggest", () => ({ + FieldValueInputSuggest: class {}, +})); + +vi.mock("src/gui/suggesters/SuggesterInputSuggest", () => ({ + SuggesterInputSuggest: class {}, +})); + +vi.mock("src/gui/suggesters/FilePickerInputSuggest", () => ({ + FilePickerInputSuggest: class { + destroy = vi.fn(); + }, +})); + +function makeFakeApp() { + return { + dom: { appContainerEl: document.body }, + keymap: { pushScope: () => {}, popScope: () => {} }, + workspace: { + containerEl: document.body, + on: () => ({}), + getActiveFile: () => null, + getActiveViewOfType: () => undefined, + }, + metadataCache: { + on: () => ({}), + getTags: () => ({}), + getFileCache: () => undefined, + isUserIgnored: () => false, + unresolvedLinks: {}, + }, + vault: { + on: () => ({}), + getMarkdownFiles: () => [], + getAllLoadedFiles: () => [], + getFiles: () => [], + getAbstractFileByPath: () => null, + }, + fileManager: { getNewFileParent: () => ({ path: "" }) }, + }; +} + +function ensureToggleClass(): void { + const proto = HTMLElement.prototype as unknown as { + toggleClass?: (cls: string, value: boolean) => void; + }; + proto.toggleClass ??= function toggleClass( + this: HTMLElement, + cls: string, + value: boolean, + ) { + this.classList.toggle(cls, value); + }; +} + +describe("OnePageInputModal link suggesters", () => { + let fakeApp: ReturnType; + + beforeEach(() => { + ensureToggleClass(); + fileSuggesters.length = 0; + tagSuggesters.length = 0; + fakeApp = makeFakeApp(); + setQuickAddInstance({ + app: fakeApp, + registerEvent: () => {}, + } as unknown as QuickAdd); + }); + + afterEach(() => { + for (const el of Array.from(document.body.children)) el.remove(); + }); + + it("attaches file and tag suggesters only to text and textarea fields", () => { + const requirements: FieldRequirement[] = [ + { id: "title", label: "Title", type: "text" }, + { id: "body", label: "Body", type: "textarea" }, + { id: "count", label: "Count", type: "number" }, + { + id: "status", + label: "Status", + type: "dropdown", + options: ["open"], + }, + { + id: "rating", + label: "Rating", + type: "slider", + sliderConfig: { min: 0, max: 10, step: 1 }, + }, + { id: "field", label: "Field", type: "field-suggest" }, + { + id: "pick", + label: "Pick", + type: "suggester", + options: ["alpha"], + }, + { + id: "file", + label: "File", + type: "file-picker", + }, + ]; + + const modal = new OnePageInputModal(fakeApp as never, requirements); + modal.waitForClose.catch(() => undefined); + const text = Array.from( + modal.contentEl.querySelectorAll("input"), + ).find( + (input) => + input.type === "text" && + !input.classList.contains("qa-onepage-file-picker__input"), + ); + const textarea = + modal.contentEl.querySelector("textarea"); + + expect(fileSuggesters.map(({ inputEl }) => inputEl)).toEqual([ + text, + textarea, + ]); + expect(tagSuggesters.map(({ inputEl }) => inputEl)).toEqual([ + text, + textarea, + ]); + expect(fileSuggesters.map(({ options }) => options)).toEqual([ + undefined, + undefined, + ]); + + modal.close(); + }); + + it("destroys every attached file and tag suggester on close", () => { + const modal = new OnePageInputModal(fakeApp as never, [ + { id: "title", label: "Title", type: "text" }, + { id: "body", label: "Body", type: "textarea" }, + ]); + modal.waitForClose.catch(() => undefined); + + modal.close(); + + expect(fileSuggesters.map(({ destroy }) => destroy)).toHaveLength(2); + expect(tagSuggesters.map(({ destroy }) => destroy)).toHaveLength(2); + for (const { destroy } of [...fileSuggesters, ...tagSuggesters]) { + expect(destroy).toHaveBeenCalledTimes(1); + } + }); +}); diff --git a/src/preflight/OnePageInputModal.peek.test.ts b/src/preflight/OnePageInputModal.peek.test.ts new file mode 100644 index 000000000..1714226aa --- /dev/null +++ b/src/preflight/OnePageInputModal.peek.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type QuickAdd from "../main"; +import { setQuickAddInstance } from "../quickAddInstance"; +import { UserCancelError } from "../errors/UserCancelError"; +import { PEEK_HIDDEN_CLASS } from "../gui/promptPeek/InputPromptPeek"; +import { PromptPeekSession } from "../gui/promptPeek/PromptPeekSession"; +import { clearVisiblePrompts } from "../gui/promptPeek/visiblePrompts"; +import type { FieldRequirement } from "./RequirementCollector"; +import { OnePageInputModal } from "./OnePageInputModal"; + +vi.mock("src/gui/suggesters/fileSuggester", () => ({ + FileSuggester: class { + destroy = vi.fn(); + }, +})); + +vi.mock("src/gui/suggesters/tagSuggester", () => ({ + TagSuggester: class { + destroy = vi.fn(); + }, +})); + +vi.mock("src/gui/suggesters/FieldValueInputSuggest", () => ({ + FieldValueInputSuggest: class {}, +})); + +vi.mock("src/gui/suggesters/SuggesterInputSuggest", () => ({ + SuggesterInputSuggest: class {}, +})); + +function makeFakeApp(selection = "") { + return { + dom: { appContainerEl: document.body }, + keymap: { pushScope: () => {}, popScope: () => {} }, + workspace: { + containerEl: document.body, + on: () => ({}), + getActiveFile: () => null, + getActiveViewOfType: () => + selection + ? { editor: { getSelection: () => selection } } + : undefined, + }, + metadataCache: { + on: () => ({}), + getTags: () => ({}), + getFileCache: () => undefined, + isUserIgnored: () => false, + unresolvedLinks: {}, + }, + vault: { + on: () => ({}), + getMarkdownFiles: () => [], + getAllLoadedFiles: () => [], + getFiles: () => [], + getAbstractFileByPath: () => null, + }, + fileManager: { getNewFileParent: () => ({ path: "" }) }, + }; +} + +function findButton(container: HTMLElement, label: string): HTMLButtonElement { + const button = Array.from(container.querySelectorAll("button")).find( + (candidate) => candidate.textContent?.includes(label), + ); + if (!(button instanceof HTMLButtonElement)) { + throw new Error(`Button not found: ${label}`); + } + return button; +} + +describe("OnePageInputModal peek", () => { + let fakeApp: ReturnType; + + beforeEach(() => { + fakeApp = makeFakeApp(); + setQuickAddInstance({ + app: fakeApp, + registerEvent: () => {}, + } as unknown as QuickAdd); + }); + + afterEach(() => { + PromptPeekSession.discard(); + clearVisiblePrompts(); + for (const el of Array.from(document.body.children)) el.remove(); + }); + + it("keeps the form alive across peek and resume, then submits its text", async () => { + const modal = new OnePageInputModal(fakeApp as never, [ + { id: "title", label: "Title", type: "text" }, + ]); + const input = modal.contentEl.querySelector("input") as HTMLInputElement; + input.value = "Draft title"; + input.dispatchEvent(new Event("input", { bubbles: true })); + + const buttons = Array.from( + modal.contentEl.querySelectorAll("button"), + ); + const peekButton = findButton(modal.contentEl, "Peek at note"); + expect(peekButton.classList.contains("qa-peek-button")).toBe(true); + expect(buttons.indexOf(findButton(modal.contentEl, "Submit"))).toBeLessThan( + buttons.indexOf(peekButton), + ); + peekButton.click(); + + expect(modal.containerEl.classList.contains(PEEK_HIDDEN_CLASS)).toBe(true); + expect(document.querySelector(".qa-peek-chip")).not.toBeNull(); + + const settled = modal.waitForClose.then( + (value) => ({ status: "resolved" as const, value }), + (error) => ({ status: "rejected" as const, error }), + ); + await Promise.resolve(); + expect(await Promise.race([settled, Promise.resolve("pending")])).toBe( + "pending", + ); + + PromptPeekSession.getActive()?.resume(); + expect(modal.containerEl.classList.contains(PEEK_HIDDEN_CLASS)).toBe(false); + findButton(modal.contentEl, "Submit").click(); + + await expect(modal.waitForClose).resolves.toEqual({ + title: "Draft title", + }); + }); + + it("rejects when the peek chip cancels the form", async () => { + const modal = new OnePageInputModal(fakeApp as never, [ + { id: "title", label: "Title", type: "text" }, + ]); + findButton(modal.contentEl, "Peek at note").click(); + + const chip = document.querySelector(".qa-peek-chip") as HTMLElement; + findButton(chip, "Cancel").click(); + + await expect(modal.waitForClose).rejects.toBeInstanceOf(UserCancelError); + }); + + it("opens Peek from the modal shortcut", async () => { + const modal = new OnePageInputModal(fakeApp as never, [ + { id: "title", label: "Title", type: "text" }, + ]); + const scope = modal.scope as unknown as { + trigger: (key: string) => unknown; + }; + + scope.trigger("E"); + + expect(modal.containerEl.classList.contains(PEEK_HIDDEN_CLASS)).toBe(true); + expect(document.querySelector(".qa-peek-chip")).not.toBeNull(); + modal.close(); + await expect(modal.waitForClose).rejects.toBeInstanceOf(UserCancelError); + }); + + it("inserts the editor selection into the last focused text field", async () => { + fakeApp = makeFakeApp("selected"); + setQuickAddInstance({ + app: fakeApp, + registerEvent: () => {}, + } as unknown as QuickAdd); + const requirements: FieldRequirement[] = [ + { + id: "first", + label: "First", + type: "text", + defaultValue: "unchanged", + }, + { id: "second", label: "Second", type: "text" }, + ]; + const modal = new OnePageInputModal(fakeApp as never, requirements); + const inputs = Array.from( + modal.contentEl.querySelectorAll("input"), + ); + const second = inputs[1]; + second.value = "before "; + second.dispatchEvent(new Event("input", { bubbles: true })); + second.focus(); + second.setSelectionRange(second.value.length, second.value.length); + + findButton(modal.contentEl, "Peek at note").click(); + const chip = document.querySelector(".qa-peek-chip") as HTMLElement; + findButton(chip, "Insert").click(); + + expect(second.value).toBe("before selected"); + findButton(modal.contentEl, "Submit").click(); + await expect(modal.waitForClose).resolves.toEqual({ + first: "unchanged", + second: "before selected", + }); + }); + + it("keeps Peek available when the form has no free-text field", async () => { + fakeApp = makeFakeApp("ignored"); + setQuickAddInstance({ + app: fakeApp, + registerEvent: () => {}, + } as unknown as QuickAdd); + const modal = new OnePageInputModal(fakeApp as never, [ + { + id: "count", + label: "Count", + type: "number", + defaultValue: "4", + }, + ]); + + findButton(modal.contentEl, "Peek at note").click(); + expect(modal.containerEl.classList.contains(PEEK_HIDDEN_CLASS)).toBe(true); + const chip = document.querySelector(".qa-peek-chip") as HTMLElement; + findButton(chip, "Insert").click(); + + expect(modal.containerEl.classList.contains(PEEK_HIDDEN_CLASS)).toBe(false); + findButton(modal.contentEl, "Submit").click(); + await expect(modal.waitForClose).resolves.toEqual({ count: "4" }); + }); +}); diff --git a/src/preflight/OnePageInputModal.test.ts b/src/preflight/OnePageInputModal.test.ts index da254d88f..94d38ed17 100644 --- a/src/preflight/OnePageInputModal.test.ts +++ b/src/preflight/OnePageInputModal.test.ts @@ -29,6 +29,27 @@ vi.mock("src/gui/imagePasteHandler", () => ({ attachImagePasteHandler: attachImagePasteHandlerMock, })); +vi.mock("src/gui/suggesters/fileSuggester", () => ({ + FileSuggester: class { + destroy = vi.fn(); + }, +})); + +vi.mock("src/gui/suggesters/tagSuggester", () => ({ + TagSuggester: class { + destroy = vi.fn(); + }, +})); + +vi.mock("src/gui/promptPeek/stylePeekButton", () => ({ + applyCompactPromptChrome: vi.fn(), + stylePeekButton: (button: T): T => { + button.buttonEl.textContent = "Peek at note"; + button.buttonEl.classList.add("qa-peek-button"); + return button; + }, +})); + vi.mock("obsidian", () => { class Modal { containerEl: HTMLElement; @@ -193,6 +214,7 @@ vi.mock("obsidian", () => { } return { + ButtonComponent, DropdownComponent, Modal, Setting, diff --git a/src/preflight/OnePageInputModal.ts b/src/preflight/OnePageInputModal.ts index d8fc0312e..f841141f6 100644 --- a/src/preflight/OnePageInputModal.ts +++ b/src/preflight/OnePageInputModal.ts @@ -1,4 +1,5 @@ import { + ButtonComponent, DropdownComponent, Modal, Notice, @@ -10,22 +11,26 @@ import { } from "obsidian"; import { FIELD_VARIABLE_PREFIX } from "src/constants"; import { createDatePicker } from "src/gui/date-picker/datePicker"; +import { InputPromptPeek } from "src/gui/promptPeek/InputPromptPeek"; +import { + applyCompactPromptChrome, + stylePeekButton, +} from "src/gui/promptPeek/stylePeekButton"; +import { PEEK_SHORTCUT_KEY } from "src/gui/promptShortcuts"; import { FieldValueInputSuggest } from "src/gui/suggesters/FieldValueInputSuggest"; import { FilePickerInputSuggest, type FilePickerOption, } from "src/gui/suggesters/FilePickerInputSuggest"; +import { FileSuggester } from "src/gui/suggesters/fileSuggester"; import { SuggesterInputSuggest } from "src/gui/suggesters/SuggesterInputSuggest"; +import { TagSuggester } from "src/gui/suggesters/tagSuggester"; import { formatISODate, parseNaturalLanguageDate } from "src/utils/dateParser"; import { formatDateAliasInline, getOrderedDateAliases, } from "src/utils/dateAliases"; import { settingsStore } from "src/settingsStore"; -type CompletionInputEvent = Event & { - fromCompletion?: boolean; -}; - import type { FieldGroup, FieldRequirement } from "./RequirementCollector"; import type { ImagePasteHandle } from "src/gui/imagePasteHandler"; import { attachImagePasteHandler } from "src/gui/imagePasteHandler"; @@ -41,6 +46,17 @@ import { promptCancelled } from "../errors/UserCancelError"; import type { PreviewDiagnostic } from "src/formatters/previewDiagnostics"; import { decodeFileValue } from "src/utils/fileSyntax"; +type CompletionInputEvent = Event & { + fromCompletion?: boolean; +}; + +type OnePageFreeTextField = { + id: string; + el: HTMLInputElement | HTMLTextAreaElement; + fileSuggester: FileSuggester; + tagSuggester: TagSuggester; +}; + /** * One row of the live preview block, with the problems that pass ran into. * @@ -109,7 +125,10 @@ export class OnePageInputModal extends Modal { private updatePreviewDebounced: () => void; private settled = false; private readonly imagePasteHandles: ImagePasteHandle[] = []; + private readonly freeTextFields: OnePageFreeTextField[] = []; + private lastFocusedFreeText: OnePageFreeTextField | undefined; private readonly filePickerSuggesters: FilePickerInputSuggest[] = []; + private readonly peek: InputPromptPeek; public waitForClose: Promise>; private resolvePromise!: (values: Record) => void; @@ -134,6 +153,28 @@ export class OnePageInputModal extends Modal { 150, true, ); + this.peek = new InputPromptPeek({ + app, + title: "Provide inputs", + containerEl: this.containerEl, + scope: this.scope, + getField: () => this.insertTarget()?.el, + getValue: () => this.insertTarget()?.el.value ?? "", + setValue: (value) => { + const target = this.insertTarget(); + if (!target) return; + this.result.set(target.id, value); + this.updatePreviewDebounced(); + }, + persistDraft: () => {}, + markDraftChanged: () => { + const target = this.insertTarget(); + if (!target) return; + this.result.set(target.id, target.el.value); + this.updatePreviewDebounced(); + }, + close: () => this.close(), + }); this.waitForClose = new Promise>( (resolve, reject) => { @@ -148,6 +189,7 @@ export class OnePageInputModal extends Modal { private display() { this.containerEl.addClass("quickAddModal", "onePageInputModal"); + applyCompactPromptChrome(this.containerEl); this.contentEl.empty(); const title = this.contentEl.createEl("h2", { text: "Provide inputs" }); @@ -182,21 +224,31 @@ export class OnePageInputModal extends Modal { // ("Example Title") over prefilled answers for 150ms. if (this.computePreview) void this.updatePreviews(); - // Action bar - const btnRow = this.contentEl.createDiv(); - new Setting(btnRow) - .addButton((btn) => - btn - .setButtonText("Submit") - .setCta() - .onClick(() => this.submit()), - ) - .addButton((btn) => - btn.setButtonText("Cancel").onClick(() => this.cancel()), - ); + const buttonBar = this.contentEl.createDiv({ + cls: "qa-prompt-actions", + }); + const primary = buttonBar.createDiv({ + cls: "qa-prompt-actions-primary", + }); + new ButtonComponent(primary) + .setButtonText("Submit") + .setCta() + .onClick(() => this.submit()); + new ButtonComponent(primary) + .setButtonText("Cancel") + .onClick(() => this.cancel()); + const secondary = buttonBar.createDiv({ + cls: "qa-prompt-actions-secondary", + }); + stylePeekButton( + new ButtonComponent(secondary) + .setButtonText("Peek at note") + .onClick(() => this.peek.peek()), + ); } onOpen() { + this.peek.onHostOpened(); // Auto-focus the first field so keyboard-first users can start typing // immediately, matching the single-field prompts. const firstField = this.contentEl.querySelector( @@ -222,6 +274,10 @@ export class OnePageInputModal extends Modal { this.submit(); return false; }); + scope.register(["Mod", "Shift"], PEEK_SHORTCUT_KEY, () => { + this.peek.peek(); + return false; + }); } } @@ -245,6 +301,7 @@ export class OnePageInputModal extends Modal { .onChange((v) => setValue(req.id, v)); input.inputEl.addClass("qa-onepage-textarea"); this.enableImagePaste(req, input.inputEl); + this.attachFreeTextBehaviors(req.id, input.inputEl); break; } case "text": { @@ -258,6 +315,7 @@ export class OnePageInputModal extends Modal { .setValue(starting) .onChange((v) => setValue(req.id, v)); this.enableImagePaste(req, input.inputEl); + this.attachFreeTextBehaviors(req.id, input.inputEl); break; } case "number": { @@ -640,6 +698,7 @@ export class OnePageInputModal extends Modal { .setValue(starting) .onChange((v) => setValue(req.id, v)); this.enableImagePaste(req, input.inputEl); + this.attachFreeTextBehaviors(req.id, input.inputEl); } } @@ -804,6 +863,26 @@ export class OnePageInputModal extends Modal { sync(); } + private attachFreeTextBehaviors( + id: string, + el: HTMLInputElement | HTMLTextAreaElement, + ): void { + const field: OnePageFreeTextField = { + id, + el, + fileSuggester: new FileSuggester(this.app, el), + tagSuggester: new TagSuggester(this.app, el), + }; + this.freeTextFields.push(field); + el.addEventListener("focus", () => { + this.lastFocusedFreeText = field; + }); + } + + private insertTarget(): OnePageFreeTextField | undefined { + return this.lastFocusedFreeText ?? this.freeTextFields[0]; + } + /** * Free-text fields accept clipboard-image paste UNLESS any scanned * occurrence of the variable was path context (file name, folder, capture @@ -935,8 +1014,15 @@ export class OnePageInputModal extends Modal { } onClose() { + this.peek.onHostClosed(); for (const handle of this.imagePasteHandles) handle.detach(); this.imagePasteHandles.length = 0; + for (const field of this.freeTextFields) { + field.fileSuggester.destroy(); + field.tagSuggester.destroy(); + } + this.freeTextFields.length = 0; + this.lastFocusedFreeText = undefined; for (const suggester of this.filePickerSuggesters) suggester.destroy(); this.filePickerSuggesters.length = 0; // Esc (or any close that isn't submit/cancel) must settle the promise, From 84c9b990d619318da88cf88c46fdf464abc9c507 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 31 Aug 2026 14:32:59 +0000 Subject: [PATCH 2/2] fix(preflight): keep tag complete after wiki links and expose combobox a11y Tag completion treated any earlier [[ as inside a wiki-link, so See [[Note]] then #tag never opened. The shared TextInputSuggest now exposes a real combobox, and one-page forms refresh the tag index once instead of once per free-text field. Co-authored-by: Christian Bager Bach Houmann --- src/gui/suggesters/suggest.test.ts | 53 +++++++++++++++++ src/gui/suggesters/suggest.ts | 59 +++++++++++++++---- src/gui/suggesters/tagSuggester.test.ts | 43 ++++++++++++++ src/gui/suggesters/tagSuggester.ts | 23 ++++++-- .../OnePageInputModal.linkSuggesters.test.ts | 33 ++++++++++- src/preflight/OnePageInputModal.ts | 16 +++-- 6 files changed, 204 insertions(+), 23 deletions(-) diff --git a/src/gui/suggesters/suggest.test.ts b/src/gui/suggesters/suggest.test.ts index af4a99b30..bac30e43e 100644 --- a/src/gui/suggesters/suggest.test.ts +++ b/src/gui/suggesters/suggest.test.ts @@ -98,6 +98,59 @@ describe("TextInputSuggest", () => { expect(input.value).toBe("Adventure"); }); + + it("exposes the input as a combobox wired to the listbox and active option", async () => { + const input = document.createElement("input"); + input.trigger = (eventName: string) => { + input.dispatchEvent(new Event(eventName, { bubbles: true })); + }; + document.body.appendChild(input); + + const suggest = new GenericTextSuggester(createApp(), input, [ + "alpha", + "alpine", + "beta", + ]); + + expect(input.getAttribute("role")).toBe("combobox"); + expect(input.getAttribute("aria-autocomplete")).toBe("list"); + expect(input.getAttribute("aria-expanded")).toBe("false"); + expect(input.getAttribute("aria-haspopup")).toBe("listbox"); + + const listboxId = input.getAttribute("aria-controls"); + expect(listboxId).toMatch(/^qa-suggest-listbox-\d+$/); + + input.focus(); + input.value = "al"; + await suggest.onInputChanged(); + + expect(input.getAttribute("aria-expanded")).toBe("true"); + expect(input.getAttribute("aria-controls")).toBe(listboxId); + + const listbox = document.getElementById(listboxId ?? ""); + expect(listbox?.getAttribute("role")).toBe("listbox"); + const options = [ + ...listbox?.querySelectorAll('[role="option"]') ?? [], + ]; + expect(options.map((option) => option.textContent)).toEqual([ + "alpha", + "alpine", + ]); + expect(input.getAttribute("aria-activedescendant")).toBe(options[0]?.id); + + for (const option of options) { + option.scrollIntoView = () => undefined; + } + const scope = ( + suggest as unknown as { scope: { trigger: (key: string) => void } } + ).scope; + scope.trigger("ArrowDown"); + expect(input.getAttribute("aria-activedescendant")).toBe(options[1]?.id); + + suggest.close(); + expect(input.getAttribute("aria-expanded")).toBe("false"); + expect(input.hasAttribute("aria-activedescendant")).toBe(false); + }); }); describe("TextInputSuggest resource lifecycle", () => { diff --git a/src/gui/suggesters/suggest.ts b/src/gui/suggesters/suggest.ts index 7ff4bab4e..501af87da 100644 --- a/src/gui/suggesters/suggest.ts +++ b/src/gui/suggesters/suggest.ts @@ -10,6 +10,8 @@ const wrapAround = (value: number, size: number): number => { return ((value % size) + size) % size; }; +let textInputSuggestSeq = 0; + type CompletionInputEvent = Event & { fromCompletion?: boolean; keepOpen?: boolean; @@ -24,10 +26,20 @@ class Suggest { private isOpen = false; private clickListener: (event: MouseEvent) => void; private mousemoveListener: (event: MouseEvent) => void; - - constructor(owner: ISuggestOwner, containerEl: HTMLElement, scope: Scope) { + private optionIdPrefix: string; + private onActiveOptionChange: (optionId: string | null) => void; + + constructor( + owner: ISuggestOwner, + containerEl: HTMLElement, + scope: Scope, + optionIdPrefix: string, + onActiveOptionChange: (optionId: string | null) => void, + ) { this.owner = owner; this.containerEl = containerEl; + this.optionIdPrefix = optionIdPrefix; + this.onActiveOptionChange = onActiveOptionChange; this.clickListener = (event: MouseEvent) => { const item = this.findSuggestionItem(event.target); @@ -111,10 +123,9 @@ class Suggest { const suggestionEl = this.containerEl.ownerDocument.createElement("div"); suggestionEl.classList.add("suggestion-item"); this.containerEl.appendChild(suggestionEl); - // Add accessibility attributes suggestionEl.setAttribute("role", "option"); suggestionEl.setAttribute("aria-selected", "false"); - suggestionEl.setAttribute("id", `suggestion-${index}`); + suggestionEl.setAttribute("id", `${this.optionIdPrefix}-option-${index}`); this.owner.renderSuggestion(value, suggestionEl); suggestionEls.push(suggestionEl); @@ -122,8 +133,13 @@ class Suggest { this.values = values; this.suggestions = suggestionEls; + if (values.length === 0) { + this.isOpen = false; + this.onActiveOptionChange(null); + return; + } this.setSelectedItem(0, false); - this.isOpen = values.length > 0; + this.isOpen = true; } useSelectedItem(event: MouseEvent | KeyboardEvent) { @@ -144,9 +160,9 @@ class Suggest { prevSelectedSuggestion?.classList.remove("is-selected"); selectedSuggestion?.classList.add("is-selected"); - // Update accessibility attributes prevSelectedSuggestion?.setAttribute("aria-selected", "false"); selectedSuggestion?.setAttribute("aria-selected", "true"); + this.onActiveOptionChange(selectedSuggestion?.id ?? null); this.selectedItem = normalizedIndex; @@ -157,6 +173,7 @@ class Suggest { close() { this.isOpen = false; + this.onActiveOptionChange(null); } getIsOpen(): boolean { @@ -192,6 +209,7 @@ export abstract class TextInputSuggest implements ISuggestOwner { private scope: Scope; private suggestEl: HTMLElement; private suggest: Suggest; + private listboxId: string; private currentRequestId = 0; private isOpen = false; private destroyed = false; @@ -247,11 +265,18 @@ export abstract class TextInputSuggest implements ISuggestOwner { suggestion.classList.add("suggestion"); this.suggestEl.appendChild(suggestion); - // Add accessibility attributes to the suggestion container + this.listboxId = `qa-suggest-listbox-${++textInputSuggestSeq}`; + suggestion.id = this.listboxId; suggestion.setAttribute("role", "listbox"); suggestion.setAttribute("aria-label", "Suggestions"); - this.suggest = new Suggest(this, suggestion, this.scope); + this.suggest = new Suggest( + this, + suggestion, + this.scope, + this.listboxId, + (optionId) => this.setActiveDescendant(optionId), + ); this.scope.register([], "Escape", this.close.bind(this)); @@ -267,9 +292,11 @@ export abstract class TextInputSuggest implements ISuggestOwner { this.inputEl.addEventListener("focus", this.focusEventListener); this.inputEl.addEventListener("blur", this.inputBlurListener); - // Set up accessibility relationship + this.inputEl.setAttribute("role", "combobox"); this.inputEl.setAttribute("aria-autocomplete", "list"); this.inputEl.setAttribute("aria-expanded", "false"); + this.inputEl.setAttribute("aria-controls", this.listboxId); + this.inputEl.setAttribute("aria-haspopup", "listbox"); this.suggestEl.addEventListener("mousedown", (event: MouseEvent) => { event.preventDefault(); @@ -385,9 +412,8 @@ export abstract class TextInputSuggest implements ISuggestOwner { this.app.keymap.pushScope(this.scope); } this.isOpen = true; - - // Update accessibility attributes this.inputEl.setAttribute("aria-expanded", "true"); + this.inputEl.setAttribute("aria-controls", this.listboxId); const inputDocument = getOwnerDocument(inputEl); const containerDocument = getOwnerDocument(container); @@ -454,9 +480,8 @@ export abstract class TextInputSuggest implements ISuggestOwner { this.app.keymap.popScope(this.scope); this.isOpen = false; - - // Update accessibility attributes this.inputEl.setAttribute("aria-expanded", "false"); + this.setActiveDescendant(null); this.suggest.close(); this.suggest.setSuggestions([]); @@ -513,6 +538,14 @@ export abstract class TextInputSuggest implements ISuggestOwner { } } + private setActiveDescendant(optionId: string | null): void { + if (optionId) { + this.inputEl.setAttribute("aria-activedescendant", optionId); + return; + } + this.inputEl.removeAttribute("aria-activedescendant"); + } + // Helper method to get current query for highlighting protected getCurrentQuery(): string { return this.currentQuery; diff --git a/src/gui/suggesters/tagSuggester.test.ts b/src/gui/suggesters/tagSuggester.test.ts index 14c213e61..87be5d8a3 100644 --- a/src/gui/suggesters/tagSuggester.test.ts +++ b/src/gui/suggesters/tagSuggester.test.ts @@ -139,4 +139,47 @@ describe("TagSuggester getSuggestions (behavior-preserving over the shared index expect(suggester.getSuggestions(value)).toEqual([]); }); + + it("still rejects a heading hash inside an unclosed wiki-link after a closed one", () => { + const suggester = suggesterFor({ "#tag": 1 }); + const input = (suggester as unknown as { inputEl: HTMLInputElement }) + .inputEl; + const value = "See [[Done]] then [[Open#ta"; + input.value = value; + input.setSelectionRange(value.length, value.length); + + expect(suggester.getSuggestions(value)).toEqual([]); + }); + + it("still suggests tags after a completed wiki-link", () => { + const suggester = suggesterFor({ + "#priority/high": 1, + "#project": 1, + "#other": 1, + }); + const input = (suggester as unknown as { inputEl: HTMLInputElement }) + .inputEl; + const value = "See [[Target Note]] then #pro"; + input.value = value; + input.setSelectionRange(value.length, value.length); + + const out = suggester.getSuggestions(value); + expect(out).toContain("#priority/high"); + expect(out).toContain("#project"); + expect(out).not.toContain("#other"); + }); + + it("does not rebuild the tag index when refreshIndex is false", () => { + const app = makeApp({ "#a": 1 }, []); + new TagSuggester(app, document.createElement("input")); + const getTags = ( + app.metadataCache as unknown as { getTags: ReturnType } + ).getTags; + getTags.mockClear(); + + new TagSuggester(app, document.createElement("input"), { + refreshIndex: false, + }); + expect(getTags).not.toHaveBeenCalled(); + }); }); diff --git a/src/gui/suggesters/tagSuggester.ts b/src/gui/suggesters/tagSuggester.ts index 59a0adcbf..07b20901f 100644 --- a/src/gui/suggesters/tagSuggester.ts +++ b/src/gui/suggesters/tagSuggester.ts @@ -5,6 +5,18 @@ import { replaceRange } from "./utils"; import { getQuickAddInstance } from "../../quickAddInstance"; import { TagIndex } from "./TagIndex"; +function isTagInsideUnclosedWikilink( + inputBeforeCursor: string, + tagMatchIndex: number, +): boolean { + const beforeTag = inputBeforeCursor.slice(0, tagMatchIndex); + const lastOpen = beforeTag.lastIndexOf("[["); + if (lastOpen === -1) { + return false; + } + return beforeTag.lastIndexOf("]]") < lastOpen; +} + export class TagSuggester extends TextInputSuggest { private lastInput = ""; private lastInputStart = 0; @@ -13,7 +25,8 @@ export class TagSuggester extends TextInputSuggest { constructor( public app: App, - public inputEl: HTMLInputElement | HTMLTextAreaElement + public inputEl: HTMLInputElement | HTMLTextAreaElement, + options?: { refreshIndex?: boolean }, ) { super(app, inputEl); @@ -25,7 +38,9 @@ export class TagSuggester extends TextInputSuggest { // Refresh on open so this prompt sees the current tags even if no // 'resolved' event has fired since the vault's tags last changed - // preserving the old per-prompt freshness, now with one shared listener. - this.tagIndex.refresh(); + if (options?.refreshIndex !== false) { + this.tagIndex.refresh(); + } } getSuggestions(inputStr: string): string[] { @@ -41,12 +56,10 @@ export class TagSuggester extends TextInputSuggest { return []; } - // Reject if we are inside a wikilink ([[ … # … ]]) - const lastWiki = inputBeforeCursor.lastIndexOf('[['); if (tagMatch.index === undefined) { return []; } - if (lastWiki !== -1 && lastWiki < tagMatch.index) { + if (isTagInsideUnclosedWikilink(inputBeforeCursor, tagMatch.index)) { return []; } diff --git a/src/preflight/OnePageInputModal.linkSuggesters.test.ts b/src/preflight/OnePageInputModal.linkSuggesters.test.ts index bafc42e43..fa005bb76 100644 --- a/src/preflight/OnePageInputModal.linkSuggesters.test.ts +++ b/src/preflight/OnePageInputModal.linkSuggesters.test.ts @@ -13,6 +13,7 @@ const { fileSuggesters, tagSuggesters } = vi.hoisted(() => ({ }>, tagSuggesters: [] as Array<{ inputEl: HTMLInputElement | HTMLTextAreaElement; + options: unknown; destroy: ReturnType; }>, })); @@ -38,8 +39,9 @@ vi.mock("src/gui/suggesters/tagSuggester", () => ({ constructor( _app: App, inputEl: HTMLInputElement | HTMLTextAreaElement, + options?: unknown, ) { - tagSuggesters.push({ inputEl, destroy: this.destroy }); + tagSuggesters.push({ inputEl, options, destroy: this.destroy }); } }, })); @@ -172,6 +174,35 @@ describe("OnePageInputModal link suggesters", () => { undefined, undefined, ]); + expect(tagSuggesters.map(({ options }) => options)).toEqual([ + { refreshIndex: true }, + { refreshIndex: false }, + ]); + + modal.close(); + }); + + it("names each free-text control from its field label", () => { + const modal = new OnePageInputModal(fakeApp as never, [ + { id: "title", label: "Title", type: "text" }, + { id: "body", label: "Body", type: "textarea" }, + ]); + modal.waitForClose.catch(() => undefined); + + const text = Array.from( + modal.contentEl.querySelectorAll("input"), + ).find((input) => input.type === "text"); + const textarea = + modal.contentEl.querySelector("textarea"); + const titleLabel = modal.contentEl.querySelector("#qa-onepage-label-title"); + const bodyLabel = modal.contentEl.querySelector("#qa-onepage-label-body"); + + expect(titleLabel?.textContent).toBe("Title"); + expect(bodyLabel?.textContent).toBe("Body"); + expect(text?.getAttribute("aria-labelledby")).toBe("qa-onepage-label-title"); + expect(textarea?.getAttribute("aria-labelledby")).toBe( + "qa-onepage-label-body", + ); modal.close(); }); diff --git a/src/preflight/OnePageInputModal.ts b/src/preflight/OnePageInputModal.ts index f841141f6..7b5ddff5f 100644 --- a/src/preflight/OnePageInputModal.ts +++ b/src/preflight/OnePageInputModal.ts @@ -301,7 +301,7 @@ export class OnePageInputModal extends Modal { .onChange((v) => setValue(req.id, v)); input.inputEl.addClass("qa-onepage-textarea"); this.enableImagePaste(req, input.inputEl); - this.attachFreeTextBehaviors(req.id, input.inputEl); + this.attachFreeTextBehaviors(req.id, input.inputEl, setting); break; } case "text": { @@ -315,7 +315,7 @@ export class OnePageInputModal extends Modal { .setValue(starting) .onChange((v) => setValue(req.id, v)); this.enableImagePaste(req, input.inputEl); - this.attachFreeTextBehaviors(req.id, input.inputEl); + this.attachFreeTextBehaviors(req.id, input.inputEl, setting); break; } case "number": { @@ -698,7 +698,7 @@ export class OnePageInputModal extends Modal { .setValue(starting) .onChange((v) => setValue(req.id, v)); this.enableImagePaste(req, input.inputEl); - this.attachFreeTextBehaviors(req.id, input.inputEl); + this.attachFreeTextBehaviors(req.id, input.inputEl, setting); } } @@ -866,12 +866,20 @@ export class OnePageInputModal extends Modal { private attachFreeTextBehaviors( id: string, el: HTMLInputElement | HTMLTextAreaElement, + setting: Setting, ): void { + if (!setting.nameEl.id) { + setting.nameEl.id = `qa-onepage-label-${id}`; + } + el.setAttribute("aria-labelledby", setting.nameEl.id); + const field: OnePageFreeTextField = { id, el, fileSuggester: new FileSuggester(this.app, el), - tagSuggester: new TagSuggester(this.app, el), + tagSuggester: new TagSuggester(this.app, el, { + refreshIndex: this.freeTextFields.length === 0, + }), }; this.freeTextFields.push(field); el.addEventListener("focus", () => {