diff --git a/src/gui/keepFocusedFieldInView.ts b/src/gui/keepFocusedFieldInView.ts
new file mode 100644
index 00000000..40c02717
--- /dev/null
+++ b/src/gui/keepFocusedFieldInView.ts
@@ -0,0 +1,20 @@
+import type { Plugin } from "obsidian";
+
+/**
+ * On a phone a QuickAdd dialog shrinks to the space above the keyboard
+ * (styles.css), which can leave the field being typed in below its scroll
+ * area, under a pinned footer. Obsidian's own keyboard scroll measures the
+ * document selection, which for an is not the field. Once the keyboard
+ * is up, scroll the focused field back into view.
+ */
+export function keepFocusedFieldInView(plugin: Plugin): void {
+ const onKeyboardShown = () => {
+ const field = activeDocument.activeElement;
+ if (field?.instanceOf(HTMLElement) && field.closest(".quickAddModal")) {
+ field.scrollIntoView({ block: "nearest" });
+ }
+ };
+ // Capacitor's Keyboard plugin dispatches these on window in the mobile app.
+ window.addEventListener("keyboardDidShow", onKeyboardShown);
+ plugin.register(() => window.removeEventListener("keyboardDidShow", onKeyboardShown));
+}
diff --git a/src/main.ts b/src/main.ts
index a756dcaf..5cd5a05c 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -57,6 +57,7 @@ import { setQuickAddInstance } from "./quickAddInstance";
import { registerQuickAddUri } from "./uri/registerQuickAddUri";
import { registerCoreCommands } from "./plugin/registerCoreCommands";
import { scheduleStartupModelSync } from "./ai/startupModelSync";
+import { keepFocusedFieldInView } from "./gui/keepFocusedFieldInView";
// The settingsStore subscriber fires on every store change — including high-frequency
// ones like folder collapse toggles. Coalesce those full-settings disk writes into one
@@ -152,6 +153,7 @@ export default class QuickAdd extends Plugin {
// with nothing but a console line. Now it reports through the same channel as
// every other failure (#1576).
registerUnhandledRejectionReporter(this);
+ keepFocusedFieldInView(this);
if (this.settings.enableRibbonIcon) {
this.addRibbonIcon("file-plus", "QuickAdd", () => {
diff --git a/src/styles.css b/src/styles.css
index cb739104..5c02a443 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -166,9 +166,31 @@
padding-right: var(--safe-area-inset-right);
}
-/* Keep the phone footer's buttons clear of the home indicator. */
+/* Keep the phone footer's buttons clear of the home indicator. While the
+ keyboard is up Obsidian sets the inset to the keyboard's height, which the
+ container below already leaves out. */
.is-phone .quickAddModal .modal > .qa-modal-footer {
- padding-bottom: max(var(--size-4-4), var(--safe-area-inset-bottom));
+ padding-bottom: max(var(--size-4-4), calc(var(--safe-area-inset-bottom) - var(--keyboard-height)));
+}
+
+/* On a phone the keyboard covers the bottom of the screen, so a dialog sits in
+ the space above it, below the status bar, with its buttons and the field
+ being typed in visible. Core's phone height is 100vh minus the top inset;
+ with the keyboard down all three lines change nothing. */
+.is-phone .modal-container.quickAddModal {
+ --dialog-max-height: calc(100vh - var(--keyboard-height) - var(--safe-area-inset-top));
+ bottom: var(--keyboard-height);
+ padding-top: min(var(--keyboard-height), var(--safe-area-inset-top));
+}
+
+/* A prompt taller than that space scrolls; its buttons stay in view. */
+.is-phone .quickAddModal .modal-content .qa-prompt-actions {
+ position: sticky;
+ bottom: calc(-1 * var(--size-4-4));
+ /* Covers the content's bottom padding, so the layout is unchanged. */
+ padding-bottom: var(--size-4-4);
+ margin-bottom: calc(-1 * var(--size-4-4));
+ background: var(--modal-background);
}
.quickAddModal.qa-choice-builder .qa-builder-footer {
@@ -1468,7 +1490,8 @@
width: min(42rem, calc(100vw - var(--size-4-8)));
min-width: min(35rem, calc(100vw - var(--size-4-8)));
max-width: calc(100vw - var(--size-4-8));
- max-height: min(80vh, 48rem);
+ /* --dialog-max-height leaves out a phone's keyboard (desktop: 85vh). */
+ max-height: min(80vh, 48rem, var(--dialog-max-height));
overflow: hidden;
}
diff --git a/tests/e2e/phone-keyboard.test.ts b/tests/e2e/phone-keyboard.test.ts
new file mode 100644
index 00000000..c0b63fd5
--- /dev/null
+++ b/tests/e2e/phone-keyboard.test.ts
@@ -0,0 +1,106 @@
+import { afterEach, expect, it } from "vitest";
+import { MacroChoice } from "../../src/types/choices/MacroChoice";
+import type IChoice from "../../src/types/choices/IChoice";
+import { createQuickAddE2EHarness } from "./e2eVault";
+import { jsLiteral, POLL_OPTS, waitForElement } from "./uiHelpers";
+
+const getContext = createQuickAddE2EHarness("phone-keyboard");
+
+// #2132: on a phone the keyboard covered the lower part of QuickAdd dialogs:
+// the date prompt's Ok, the multi-select's Done, and the macro builder's field
+// under its pinned footer. Obsidian sets --keyboard-height while the keyboard
+// is up; the dialog now sits in the space above it.
+const KEYBOARD = 345;
+
+// Settings shows its tab list instead of a page on a phone, so the macro test
+// emulates the phone once the builder is open.
+const emulatePhoneWithKeyboard = () => getContext().obsidian.dev.evalJson(`(() => {
+ window.__qaPhoneClasses = document.body.className;
+ document.body.classList.remove("is-tablet");
+ document.body.classList.add("is-mobile", "is-phone");
+ document.documentElement.style.setProperty("--keyboard-height", "${KEYBOARD}px");
+ return true;
+ })()`);
+
+afterEach(async () => {
+ const { obsidian } = getContext();
+ await obsidian.dev.evalJson(`(() => {
+ if (window.__qaPhoneClasses !== undefined) document.body.className = window.__qaPhoneClasses;
+ delete window.__qaPhoneClasses;
+ document.documentElement.style.removeProperty("--keyboard-height");
+ for (const container of [...document.querySelectorAll(".modal-container")].reverse()) {
+ const close = [...container.querySelectorAll("button")].find((b) => /^(Cancel|Done)$/.test(b.textContent.trim()));
+ (close ?? container.querySelector(".modal-close-button"))?.click();
+ }
+ app.setting.close();
+ return true;
+ })()`);
+ await expect.poll(() => obsidian.dev.evalJson(
+ 'document.querySelectorAll(".modal-container").length',
+ ), POLL_OPTS).toBe(0);
+});
+
+const aboveKeyboard = (selector: string) => `(() => {
+ const el = [...document.querySelectorAll(".modal-container")].pop().querySelector(${jsLiteral(selector)});
+ const rect = el.getBoundingClientRect();
+ return rect.top >= 0 && rect.bottom <= innerHeight - ${KEYBOARD};
+})()`;
+
+it("keeps the date prompt's field and Ok above the keyboard", async () => {
+ const { obsidian } = getContext();
+ await emulatePhoneWithKeyboard();
+ await obsidian.dev.evalJson(`(() => {
+ app.plugins.plugins.quickadd.api.format("{{VDATE:due,YYYY-MM-DD}}").catch(() => {});
+ return true;
+ })()`);
+ await waitForElement(obsidian, ".qaDatePrompt .qa-date-picker");
+ expect(await obsidian.dev.evalJson(aboveKeyboard(".qa-vdate-input"))).toBe(true);
+ expect(await obsidian.dev.evalJson(aboveKeyboard(".qa-prompt-actions-primary button.mod-cta"))).toBe(true);
+});
+
+// Enough options that the dialog is taller than the space above the keyboard.
+// The desktop window is wider than 540px, as a phone is in landscape, so the
+// multi-select's wide-screen height cap applies.
+const OPTIONS = Array.from({ length: 20 }, (_, i) => `Option ${i + 1}`).join(",");
+
+it("keeps the multi-select's Done above the keyboard", async () => {
+ const { obsidian } = getContext();
+ await emulatePhoneWithKeyboard();
+ await obsidian.dev.evalJson(`(() => {
+ app.plugins.plugins.quickadd.api.format("{{VALUE:${OPTIONS}|multi}}").catch(() => {});
+ return true;
+ })()`);
+ await waitForElement(obsidian, ".qaMultiSuggester .qa-multi-actions");
+ expect(await obsidian.dev.evalJson(aboveKeyboard(".qa-multi-actions button.mod-cta"))).toBe(true);
+});
+
+it("brings the macro builder's focused field out from under its footer once the keyboard is up", async () => {
+ const { obsidian, plugin } = getContext();
+ const macro = new MacroChoice("Keyboard macro");
+ await plugin.data<{ choices: IChoice[] }>().patch((data) => {
+ data.choices = [macro];
+ });
+ await plugin.reload({ waitUntilReady: true });
+ await obsidian.dev.evalJson("app.setting.open(); app.setting.openTabById('quickadd'); true");
+ await waitForElement(obsidian, '[aria-label="Configure Keyboard macro"]');
+ await obsidian.dev.evalJson(`document.querySelector('[aria-label="Configure Keyboard macro"]').click(), true`);
+ await waitForElement(obsidian, ".macroBuilder .qa-modal-footer");
+ await emulatePhoneWithKeyboard();
+
+ const coveredByFooter = `(() => {
+ const modal = document.querySelector(".macroBuilder");
+ const field = [...modal.querySelectorAll("input")].find((input) => input.placeholder.startsWith("Start typing script"));
+ return field.getBoundingClientRect().bottom > modal.querySelector(".qa-modal-footer").getBoundingClientRect().top;
+ })()`;
+ // The field sits low in the builder: with the keyboard taking the bottom of
+ // the screen it starts under the footer, as on a phone.
+ expect(await obsidian.dev.evalJson(`(() => {
+ const modal = document.querySelector(".macroBuilder");
+ modal.querySelector(".modal-content").scrollTop = 0;
+ [...modal.querySelectorAll("input")].find((input) => input.placeholder.startsWith("Start typing script")).focus({ preventScroll: true });
+ return ${coveredByFooter};
+ })()`)).toBe(true);
+
+ await obsidian.dev.evalJson('window.dispatchEvent(new Event("keyboardDidShow")), true');
+ expect(await obsidian.dev.evalJson(coveredByFooter)).toBe(false);
+});