Add autocomplete suggestions for pill inputs (#26)

* Add autocomplete suggestions for tags/multitext pill inputs

Show a completion dropdown with known vault values when entering
tags, aliases, or multitext property values in the bulk edit modal.
Uses AbstractInputSuggest attached to the pill input element.

Suggestions are collected from frontmatter across all vault files,
filtered to exclude already-added pills, and normalized consistently
with tag input (leading # stripped for matching).

* Fix focusout committing partial text when clicking a suggestion

When the user clicks a suggestion in the dropdown, the pill container's
focusout handler fires before selectSuggestion, which would commit the
partially typed query as an unintended pill. Defer the focusout commit
via requestAnimationFrame so selectSuggestion can set a flag and clear
the input first.

* Scope focusout suppression to mouse-triggered suggestions only

Keyboard selections are captured by the suggest's Scope before
focusout fires, so the didSelect flag is only needed for mouse
clicks. Setting it unconditionally left the flag armed after
keyboard selections, which could discard the next legitimate
blur commit.

* Pre-normalize tag values in suggestion list to strip # prefix

Known values for tags are now normalized (leading # removed) before
being passed to the suggest, so the dropdown never shows # prefixes.
Deduplicates after normalization in case both #foo and foo exist.

* Re-sort tag suggestions after normalization

Stripping # prefixes can break the original alphabetical order
from getPropertyValues(). Re-sort after normalize and dedup.

* Fix suggestion selection not adding pill

The selectSuggestion override replaced the base class implementation
that invokes the onSelect callback, so addPill was never called.
Use a direct callback property (onValueSelected) called from within
selectSuggestion, matching the PropertyNameSuggest pattern.
This commit is contained in:
Gary Ritchie 2026-04-03 08:52:51 -06:00 committed by GitHub
parent a0d7b6d93b
commit a7eacdace0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 124 additions and 8 deletions

View file

@ -1,6 +1,6 @@
import {App, Modal, Notice, setIcon, Setting, TFile} from "obsidian";
import {AbstractInputSuggest, App, Modal, Notice, setIcon, Setting, TFile} from "obsidian";
import type BulkPropertiesPlugin from "./main";
import {getSelectedFiles} from "./files";
import {getPropertyValues, getSelectedFiles} from "./files";
import {confirmEmptyValue} from "./confirm-modal";
import {withProgress} from "./progress";
import {makeToggleAccessible, updateToggleAriaChecked} from "./accessible-toggle";
@ -50,6 +50,61 @@ function coerceValue(raw: string, type: string): unknown {
}
}
class PropertyValueSuggest extends AbstractInputSuggest<string> {
private knownValues: string[];
private currentPills: () => string[];
private normalizeFn: (v: string) => string;
constructor(
app: App,
inputEl: HTMLInputElement,
knownValues: string[],
currentPills: () => string[],
normalize: (v: string) => string,
) {
super(app, inputEl);
this.knownValues = knownValues;
this.currentPills = currentPills;
this.normalizeFn = normalize;
}
override getSuggestions(query: string): string[] {
const lower = this.normalizeFn(query).toLowerCase();
const existing = new Set(this.currentPills().map(this.normalizeFn));
return this.knownValues.filter(v =>
this.normalizeFn(v).toLowerCase().includes(lower)
&& !existing.has(this.normalizeFn(v)),
);
}
override renderSuggestion(value: string, el: HTMLElement): void {
el.setText(value);
}
onValueSelected?: (value: string, evt: MouseEvent | KeyboardEvent) => void;
override selectSuggestion(
value: string,
evt: MouseEvent | KeyboardEvent,
): void {
if (evt instanceof MouseEvent) {
this.didSelect = true;
}
this.setValue("");
this.close();
this.onValueSelected?.(value, evt);
}
/**
* Set to true when a suggestion is selected via mouse click. The pill
* container's focusout handler defers its commit and checks this flag
* to avoid committing partial query text as a pill. Only mouse clicks
* cause the focusout race; keyboard selections are captured by the
* suggest's Scope before focusout fires.
*/
didSelect = false;
}
export class BulkEditModal extends Modal {
private plugin: BulkPropertiesPlugin;
private fileSelection: Map<TFile, boolean>;
@ -423,6 +478,29 @@ export class BulkEditModal extends Modal {
pillInput.focus();
};
let suggest: PropertyValueSuggest | null = null;
const normalize = type === "tags"
? (v: string) => v.replace(/^#/, "")
: (v: string) => v;
const knownValues = [...new Set(
getPropertyValues(this.app, this.selectedProperty)
.map(normalize)
.filter(v => v !== ""),
)].sort((a, b) => a.localeCompare(b));
if (knownValues.length > 0) {
suggest = new PropertyValueSuggest(
this.app,
pillInput,
knownValues,
() => pills,
normalize,
);
suggest.onValueSelected = (value) => {
addPill(value, true);
this.updateCountText();
};
}
pillInput.addEventListener("keydown", (e: KeyboardEvent) => {
if (e.isComposing) return;
if (e.key === "Enter" || e.key === ",") {
@ -474,13 +552,24 @@ export class BulkEditModal extends Modal {
) {
return;
}
if (pillInput.value.trim() !== "") {
const val = pillInput.value;
pillInput.value = "";
if (!addPill(val)) {
pillInput.value = val;
const commitPending = () => {
if (suggest?.didSelect) {
suggest.didSelect = false;
return;
}
this.updateCountText();
if (pillInput.value.trim() !== "") {
const val = pillInput.value;
pillInput.value = "";
if (!addPill(val)) {
pillInput.value = val;
}
this.updateCountText();
}
};
if (suggest) {
requestAnimationFrame(commitPending);
} else {
commitPending();
}
});

View file

@ -27,3 +27,30 @@ export function getFilesWithProperty(
}
return files.sort((a, b) => a.path.localeCompare(b.path));
}
/**
* Returns sorted unique string values for a given frontmatter property
* across all markdown files in the vault.
*/
export function getPropertyValues(
app: App,
propertyName: string,
): string[] {
const values = new Set<string>();
for (const file of app.vault.getMarkdownFiles()) {
const cache = app.metadataCache.getFileCache(file);
if (!cache?.frontmatter) continue;
if (!Object.prototype.hasOwnProperty.call(cache.frontmatter, propertyName)) continue;
const raw: unknown = cache.frontmatter[propertyName];
if (Array.isArray(raw)) {
for (const item of raw) {
if (typeof item === "string" && item !== "") {
values.add(item);
}
}
} else if (typeof raw === "string" && raw !== "") {
values.add(raw);
}
}
return [...values].sort((a, b) => a.localeCompare(b));
}