Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/src/content/docs/docs/Advanced/onePageInputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
10 changes: 5 additions & 5 deletions docs/src/content/docs/docs/ControllingPrompts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down
6 changes: 3 additions & 3 deletions docs/src/content/docs/docs/SuggesterSystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
53 changes: 53 additions & 0 deletions src/gui/suggesters/suggest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('[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", () => {
Expand Down
59 changes: 46 additions & 13 deletions src/gui/suggesters/suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -24,10 +26,20 @@ class Suggest<T> {
private isOpen = false;
private clickListener: (event: MouseEvent) => void;
private mousemoveListener: (event: MouseEvent) => void;

constructor(owner: ISuggestOwner<T>, containerEl: HTMLElement, scope: Scope) {
private optionIdPrefix: string;
private onActiveOptionChange: (optionId: string | null) => void;

constructor(
owner: ISuggestOwner<T>,
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);
Expand Down Expand Up @@ -111,19 +123,23 @@ class Suggest<T> {
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);
});

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) {
Expand All @@ -144,9 +160,9 @@ class Suggest<T> {
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;

Expand All @@ -157,6 +173,7 @@ class Suggest<T> {

close() {
this.isOpen = false;
this.onActiveOptionChange(null);
}

getIsOpen(): boolean {
Expand Down Expand Up @@ -192,6 +209,7 @@ export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
private scope: Scope;
private suggestEl: HTMLElement;
private suggest: Suggest<T>;
private listboxId: string;
private currentRequestId = 0;
private isOpen = false;
private destroyed = false;
Expand Down Expand Up @@ -247,11 +265,18 @@ export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
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));

Expand All @@ -267,9 +292,11 @@ export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
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();
Expand Down Expand Up @@ -385,9 +412,8 @@ export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
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);
Expand Down Expand Up @@ -454,9 +480,8 @@ export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {

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([]);
Expand Down Expand Up @@ -513,6 +538,14 @@ export abstract class TextInputSuggest<T> implements ISuggestOwner<T> {
}
}

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;
Expand Down
43 changes: 43 additions & 0 deletions src/gui/suggesters/tagSuggester.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof vi.fn> }
).getTags;
getTags.mockClear();

new TagSuggester(app, document.createElement("input"), {
refreshIndex: false,
});
expect(getTags).not.toHaveBeenCalled();
});
});
23 changes: 18 additions & 5 deletions src/gui/suggesters/tagSuggester.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
private lastInput = "";
private lastInputStart = 0;
Expand All @@ -13,7 +25,8 @@ export class TagSuggester extends TextInputSuggest<string> {

constructor(
public app: App,
public inputEl: HTMLInputElement | HTMLTextAreaElement
public inputEl: HTMLInputElement | HTMLTextAreaElement,
options?: { refreshIndex?: boolean },
) {
super(app, inputEl);

Expand All @@ -25,7 +38,9 @@ export class TagSuggester extends TextInputSuggest<string> {
// 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[] {
Expand All @@ -41,12 +56,10 @@ export class TagSuggester extends TextInputSuggest<string> {
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 [];
}

Expand Down
Loading