Skip to content

Commit 751e5e8

Browse files
authored
feat: add searchable multi-select prompts (#1648)
* feat: add searchable multi-select prompts * docs: show searchable checkbox prompt * fix: normalize multi-select search consistently * fix: preserve mobile multi-select layout * docs: remove checkbox prompt release callout
1 parent 4c0e8a1 commit 751e5e8

9 files changed

Lines changed: 809 additions & 67 deletions

File tree

131 KB
Loading

docs/src/content/docs/docs/QuickAddAPI.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,8 @@ module.exports = async (params) => {
388388
### `checkboxPrompt(items: string[], selectedItems?: string[]): Promise<string[]>`
389389
Opens a checkbox prompt allowing multiple selections.
390390
391+
![Searchable checkbox prompt with selected options and fixed action buttons](/img/checkbox-prompt-searchable.png)
392+
391393
**Parameters:**
392394
- `items`: Array of options to display
393395
- `selectedItems`: (Optional) Array of pre-selected items

src/gui/GenericCheckboxPrompt/genericCheckboxPrompt.audit-api-prompts.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,28 @@ function buttonByText(
141141
return target;
142142
}
143143

144+
function searchInput(
145+
prompt: InstanceType<typeof GenericCheckboxPrompt>,
146+
): HTMLInputElement {
147+
const contentEl = (prompt as unknown as { contentEl: HTMLElement }).contentEl;
148+
const input = contentEl.querySelector<HTMLInputElement>(
149+
".qa-searchable-multi-select__search",
150+
);
151+
if (!input) throw new Error("search input not found");
152+
return input;
153+
}
154+
155+
function visibleRows(
156+
prompt: InstanceType<typeof GenericCheckboxPrompt>,
157+
): HTMLLabelElement[] {
158+
const contentEl = (prompt as unknown as { contentEl: HTMLElement }).contentEl;
159+
return Array.from(
160+
contentEl.querySelectorAll<HTMLLabelElement>(
161+
".qa-searchable-multi-select__option",
162+
),
163+
);
164+
}
165+
144166
describe("GenericCheckboxPrompt header + cancel (audit: prompts-gui-checkbox-prompt)", () => {
145167
const app = {} as App;
146168

@@ -183,4 +205,48 @@ describe("GenericCheckboxPrompt header + cancel (audit: prompts-gui-checkbox-pro
183205

184206
await expect(promise).resolves.toEqual(["a"]);
185207
});
208+
209+
it("filters quickly without losing preselection or result ordering", async () => {
210+
const prompt = new GenericCheckboxPrompt(
211+
app,
212+
["Alpha", "Beta", "Gamma"],
213+
["Gamma"],
214+
);
215+
const promise = prompt.promise;
216+
const input = searchInput(prompt);
217+
input.value = "alpha";
218+
input.dispatchEvent(new Event("input", { bubbles: true }));
219+
220+
expect(visibleRows(prompt)).toHaveLength(1);
221+
visibleRows(prompt)[0].click();
222+
buttonByText(prompt, "Submit").click();
223+
224+
await expect(promise).resolves.toEqual(["Gamma", "Alpha"]);
225+
});
226+
227+
it("uses one shared selection for duplicate option values", async () => {
228+
const prompt = new GenericCheckboxPrompt(app, ["Same", "Same"], []);
229+
const promise = prompt.promise;
230+
const rows = visibleRows(prompt);
231+
rows[0].click();
232+
233+
const checkboxes = rows.map((row) =>
234+
row.querySelector<HTMLInputElement>('input[type="checkbox"]'),
235+
);
236+
expect(checkboxes.every((checkbox) => checkbox?.checked)).toBe(true);
237+
buttonByText(prompt, "Submit").click();
238+
await expect(promise).resolves.toEqual(["Same"]);
239+
});
240+
241+
it("distinguishes an empty submission from cancellation", async () => {
242+
const submitted = new GenericCheckboxPrompt(app, [], []);
243+
const submittedPromise = submitted.promise;
244+
buttonByText(submitted, "Submit").click();
245+
await expect(submittedPromise).resolves.toEqual([]);
246+
247+
const cancelled = new GenericCheckboxPrompt(app, [], []);
248+
const cancelledPromise = cancelled.promise;
249+
buttonByText(cancelled, "Cancel").click();
250+
await expect(cancelledPromise).rejects.toBeInstanceOf(UserCancelError);
251+
});
186252
});

src/gui/GenericCheckboxPrompt/genericCheckboxPrompt.ts

Lines changed: 33 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import type { App } from "obsidian";
2-
import { ButtonComponent, Modal, ToggleComponent } from "obsidian";
2+
import { ButtonComponent, Modal } from "obsidian";
33
import { promptCancelled } from "../../errors/UserCancelError";
4+
import SearchableMultiSelect from "../SearchableMultiSelect/searchableMultiSelect";
45

56
export default class GenericCheckboxPrompt extends Modal {
67
private resolvePromise: (value: string[]) => void;
78
private rejectPromise: (reason?: unknown) => void;
89
public promise: Promise<string[]>;
910
private resolved: boolean;
1011
private _selectedItems: string[];
12+
private picker: SearchableMultiSelect<string>;
1113

1214
public static Open(
1315
app: App,
@@ -42,13 +44,18 @@ export default class GenericCheckboxPrompt extends Modal {
4244

4345
this.display();
4446
this.open();
47+
this.picker.focusSearchOnOpen();
4548
}
4649

4750
private display() {
4851
this.contentEl.empty();
49-
this.containerEl.addClass("quickAddModal", "checkboxPrompt");
52+
this.containerEl.addClass(
53+
"quickAddModal",
54+
"qaSearchableMultiSelectModal",
55+
"checkboxPrompt",
56+
);
5057
if (this.header) this.titleEl.textContent = this.header;
51-
this.addCheckboxRows();
58+
this.addSearchableOptions();
5259
this.addSubmitButton();
5360
}
5461

@@ -58,37 +65,32 @@ export default class GenericCheckboxPrompt extends Modal {
5865
if (!this.resolved) this.rejectPromise(promptCancelled());
5966
}
6067

61-
private addCheckboxRows() {
62-
const rowContainer: HTMLDivElement = this.contentEl.createDiv(
63-
"checkboxRowContainer"
64-
);
65-
this.items.forEach((item) => this.addCheckboxRow(item, rowContainer));
66-
}
67-
68-
private addCheckboxRow(item: string, container: HTMLDivElement) {
69-
const checkboxRow: HTMLDivElement = container.createDiv("checkboxRow");
70-
71-
checkboxRow.createEl("span", {
72-
text: item,
73-
});
74-
const checkbox: ToggleComponent = new ToggleComponent(checkboxRow);
75-
checkbox
76-
.setTooltip(`Toggle ${item}`)
77-
.setValue(this._selectedItems.contains(item))
78-
.onChange((value) => {
79-
if (value) this._selectedItems.push(item);
80-
else {
81-
const index = this._selectedItems.findIndex(
82-
(value) => item === value
83-
);
84-
this._selectedItems.splice(index, 1);
68+
private addSearchableOptions() {
69+
this.picker = new SearchableMultiSelect(this.contentEl, {
70+
items: this.items.map((item) => ({
71+
key: item,
72+
value: item,
73+
label: item,
74+
})),
75+
isSelected: ({ value }) => this._selectedItems.includes(value),
76+
onToggle: ({ value }, selected) => {
77+
if (selected) {
78+
if (!this._selectedItems.includes(value)) {
79+
this._selectedItems.push(value);
80+
}
81+
return;
8582
}
86-
});
83+
this._selectedItems = this._selectedItems.filter(
84+
(selectedItem) => selectedItem !== value,
85+
);
86+
},
87+
getSelectedCount: () => new Set(this._selectedItems).size,
88+
});
8789
}
8890

8991
private addSubmitButton() {
9092
const submitButtonContainer: HTMLDivElement = this.contentEl.createDiv(
91-
"submitButtonContainer"
93+
"submitButtonContainer",
9294
);
9395
const submitButton: ButtonComponent = new ButtonComponent(
9496
submitButtonContainer
@@ -97,7 +99,7 @@ export default class GenericCheckboxPrompt extends Modal {
9799
submitButton
98100
.setButtonText("Submit")
99101
.setCta()
100-
.onClick((evt) => {
102+
.onClick(() => {
101103
this.resolved = true;
102104
this.resolvePromise(this._selectedItems);
103105

@@ -111,7 +113,7 @@ export default class GenericCheckboxPrompt extends Modal {
111113
submitButtonContainer
112114
);
113115

114-
cancelButton.setButtonText("Cancel").onClick((evt) => {
116+
cancelButton.setButtonText("Cancel").onClick(() => {
115117
this.close();
116118
});
117119
}

src/gui/MultiSuggester/multiSuggester.preselect-1429.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from "vitest";
22
import { Modal } from "obsidian";
3+
import { UserCancelError } from "../../errors/UserCancelError";
34

45
// Mirror the polyfills the sibling MultiSuggester test installs: the Modal stub
56
// calls titleEl.setText and subclasses call super.onClose().
@@ -27,6 +28,15 @@ function listCheckboxes(suggester: { contentEl: HTMLElement }) {
2728
return Array.from(list.querySelectorAll<HTMLInputElement>("input[type=checkbox]"));
2829
}
2930

31+
function search(suggester: { contentEl: HTMLElement }, value: string): void {
32+
const input = suggester.contentEl.querySelector<HTMLInputElement>(
33+
".qa-searchable-multi-select__search",
34+
);
35+
if (!input) throw new Error("search input not found");
36+
input.value = value;
37+
input.dispatchEvent(new Event("input", { bubbles: true }));
38+
}
39+
3040
describe("MultiSuggester preselection (issue #1429)", () => {
3141
it("resolves preselected option-list values on Done with no interaction", async () => {
3242
const suggester = new MultiSuggester(
@@ -108,4 +118,51 @@ describe("MultiSuggester preselection (issue #1429)", () => {
108118
clickButton(suggester, "Done");
109119
await expect(suggester.waitForClose).resolves.toEqual([]);
110120
});
121+
122+
it("preserves preselection while filtering and returns source order", async () => {
123+
const suggester = new MultiSuggester(
124+
{} as never,
125+
["Alpha", "Beta", "Gamma"],
126+
["alpha", "beta", "gamma"],
127+
{ preselected: ["gamma"] },
128+
);
129+
search(suggester, "alpha");
130+
const [alpha] = listCheckboxes(suggester);
131+
alpha.click();
132+
clickButton(suggester, "Done");
133+
134+
await expect(suggester.waitForClose).resolves.toEqual(["alpha", "gamma"]);
135+
});
136+
137+
it("keeps duplicate labels independent and duplicate values synchronized", async () => {
138+
const suggester = new MultiSuggester(
139+
{} as never,
140+
["Duplicate", "Duplicate", "Shared one", "Shared two"],
141+
["first", "second", "shared", "shared"],
142+
);
143+
const checkboxes = listCheckboxes(suggester);
144+
checkboxes[1].click();
145+
checkboxes[2].click();
146+
147+
expect(checkboxes[0].checked).toBe(false);
148+
expect(checkboxes[1].checked).toBe(true);
149+
expect(checkboxes[2].checked).toBe(true);
150+
expect(checkboxes[3].checked).toBe(true);
151+
clickButton(suggester, "Done");
152+
await expect(suggester.waitForClose).resolves.toEqual([
153+
"second",
154+
"shared",
155+
"shared",
156+
]);
157+
});
158+
159+
it("keeps cancellation distinct from an empty Done submission", async () => {
160+
const cancelled = new MultiSuggester({} as never, [], []);
161+
clickButton(cancelled, "Cancel");
162+
await expect(cancelled.waitForClose).rejects.toBeInstanceOf(UserCancelError);
163+
164+
const submitted = new MultiSuggester({} as never, [], []);
165+
clickButton(submitted, "Done");
166+
await expect(submitted.waitForClose).resolves.toEqual([]);
167+
});
111168
});

src/gui/MultiSuggester/multiSuggester.ts

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@ import type { App } from "obsidian";
22
import { Modal, Notice, Setting } from "obsidian";
33
import { normalizeDisplayItem } from "../suggesters/utils";
44
import { promptCancelled } from "../../errors/UserCancelError";
5+
import SearchableMultiSelect, {
6+
type SearchableMultiSelectItem,
7+
} from "../SearchableMultiSelect/searchableMultiSelect";
58

69
export interface MultiSuggesterOptions {
710
/** Modal title / prompt header. */
@@ -39,6 +42,7 @@ export default class MultiSuggester extends Modal {
3942
private readonly opts: MultiSuggesterOptions;
4043
private readonly selected = new Set<string>();
4144
private readonly customValues: string[] = [];
45+
private picker: SearchableMultiSelect<string>;
4246
// The in-progress custom-value text. Held on the instance (not a render-scoped
4347
// local) so submit() can fold an un-"Add"ed draft into the result instead of
4448
// silently dropping it.
@@ -71,6 +75,7 @@ export default class MultiSuggester extends Modal {
7175
});
7276
this.render();
7377
this.open();
78+
this.picker.focusSearchOnOpen();
7479
}
7580

7681
/**
@@ -95,30 +100,28 @@ export default class MultiSuggester extends Modal {
95100
}
96101

97102
private render() {
98-
this.containerEl.addClass("quickAddModal", "qaMultiSuggester");
103+
this.containerEl.addClass(
104+
"quickAddModal",
105+
"qaSearchableMultiSelectModal",
106+
"qaMultiSuggester",
107+
);
99108
this.titleEl.setText(this.opts.placeholder ?? "Select one or more");
100109
const { contentEl } = this;
101110
contentEl.empty();
102111

103112
const list = contentEl.createDiv({ cls: "qa-multi-list" });
104-
const rows = [
105-
...this.items.map((value, i) => ({
106-
value,
107-
display: normalizeDisplayItem(this.displayItems[i] ?? value),
108-
})),
109-
...this.customValues.map((value) => ({ value, display: value })),
110-
];
111-
for (const { value, display } of rows) {
112-
new Setting(list).setName(display).addToggle((toggle) =>
113-
toggle.setValue(this.selected.has(value)).onChange((on) => {
114-
if (on) this.selected.add(value);
115-
else this.selected.delete(value);
116-
}),
117-
);
118-
}
113+
this.picker = new SearchableMultiSelect(list, {
114+
items: this.getSearchableItems(),
115+
isSelected: ({ value }) => this.selected.has(value),
116+
onToggle: ({ value }, selected) => {
117+
if (selected) this.selected.add(value);
118+
else this.selected.delete(value);
119+
},
120+
getSelectedCount: () => this.selected.size,
121+
});
119122

120123
if (this.opts.allowCustomValue) {
121-
new Setting(contentEl)
124+
const customSetting = new Setting(contentEl)
122125
.setName("Add a custom value")
123126
.addText((text) => {
124127
text
@@ -139,9 +142,11 @@ export default class MultiSuggester extends Modal {
139142
.addButton((btn) =>
140143
btn.setButtonText("Add").onClick(() => this.commitDraft()),
141144
);
145+
customSetting.settingEl.addClass("qa-multi-custom");
142146
}
143147

144148
const buttons = new Setting(contentEl);
149+
buttons.settingEl.addClass("qa-multi-actions");
145150
buttons.addButton((btn) =>
146151
btn.setButtonText("Done").setCta().onClick(() => this.submit()),
147152
);
@@ -162,6 +167,22 @@ export default class MultiSuggester extends Modal {
162167
}
163168
}
164169

170+
private getSearchableItems(): SearchableMultiSelectItem<string>[] {
171+
return [
172+
...this.items.map((value, index) => ({
173+
key: value,
174+
value,
175+
label: normalizeDisplayItem(this.displayItems[index] ?? value),
176+
searchText: value,
177+
})),
178+
...this.customValues.map((value) => ({
179+
key: value,
180+
value,
181+
label: value,
182+
})),
183+
];
184+
}
185+
165186
/**
166187
* Commit the current custom-value draft into the selection. Returns `false`
167188
* (with a Notice) for blank or duplicate input so the user gets feedback instead

0 commit comments

Comments
 (0)