From 63fde61c8fccf2b2e01dac0d551d2bcf7f4973cf Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 26 Sep 2026 18:06:51 +0200 Subject: [PATCH 1/3] fix(one-page): align every input in one control column Textareas and sliders filled the control box, whose width came from the flex split with the label, so a shorter label pushed them left of the text inputs. The form now gives every row the same control column. --- src/styles.css | 17 ++++++++++--- tests/e2e/one-page-alignment.test.ts | 38 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 tests/e2e/one-page-alignment.test.ts diff --git a/src/styles.css b/src/styles.css index a8b701d24..4905594ab 100644 --- a/src/styles.css +++ b/src/styles.css @@ -323,6 +323,18 @@ color: var(--text-muted); } +/* One control column for every row, so inputs line up however long their + labels are. Stacked rows (dates, narrow screens, discovery forms) override + the width below. */ +.quickAddModal.onePageInputModal .setting-item-control { + flex: none; + width: 60%; +} + +.quickAddModal.onePageInputModal .setting-item-control > input { + width: 100%; +} + .quickAddModal .qa-onepage-textarea { width: 100%; height: 120px; @@ -333,10 +345,6 @@ color: var(--text-muted); } -.quickAddModal .qa-onepage-file-picker-setting .setting-item-control { - width: min(28rem, 62%); -} - .quickAddModal .qa-onepage-file-picker { display: flex; flex-direction: column; @@ -428,6 +436,7 @@ .quickAddModal .qa-onepage-slider input[type="range"] { width: 100%; + margin: 0; } .quickAddModal .qa-onepage-slider input[type="number"] { diff --git a/tests/e2e/one-page-alignment.test.ts b/tests/e2e/one-page-alignment.test.ts new file mode 100644 index 000000000..9e0c6a1b8 --- /dev/null +++ b/tests/e2e/one-page-alignment.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "vitest"; +import { createQuickAddE2EHarness } from "./e2eVault"; +import { waitForElement } from "./uiHelpers"; + +const getContext = createQuickAddE2EHarness("one-page-alignment"); + +it("starts every one-page control at the same x regardless of label length", async () => { + const { obsidian } = getContext(); + try { + await obsidian.dev.evalJson(`(() => { + const inputs = []; + for (const type of ['text', 'textarea', 'number', 'slider', 'suggester']) { + for (const label of ['A', 'A considerably longer label for this field']) { + inputs.push({ id: type + label, label, type, options: ['x'], sliderConfig: { min: 0, max: 10 } }); + } + } + void app.plugins.plugins.quickadd.api.requestInputs(inputs).catch(() => undefined); + return true; + })()`); + await waitForElement(obsidian, ".onePageInputModal .qa-onepage-slider"); + const lefts = await obsidian.dev.evalJson>(`(() => Object.fromEntries( + [...[...document.querySelectorAll('.onePageInputModal')].at(-1).querySelectorAll('.setting-item')].map((row, index) => [ + index + ': ' + row.querySelector('.setting-item-name').textContent, + row.querySelector('input, textarea').getBoundingClientRect().left, + ]), + ))()`); + const values = Object.values(lefts); + expect(values).toHaveLength(10); + for (const [row, left] of Object.entries(lefts)) { + expect(left, row).toBeCloseTo(values[0], 0); + } + } finally { + await obsidian.dev.evalJson(`(() => { + [...[...document.querySelectorAll('.onePageInputModal')].at(-1).querySelectorAll('button')].find(e => e.textContent === 'Cancel')?.click(); + return true; + })()`); + } +}); From 1f24818fca6e516c6aa66fab5af7dbc241352750 Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 26 Sep 2026 18:06:51 +0200 Subject: [PATCH 2/3] fix(choice-list): open the New choice menu under its button The menu was anchored at the button's left edge only, so whenever it overflowed the window Obsidian flipped it to end at that point, leaving it entirely left of the button. Passing the button's width with overlap keeps it under the button; the primary button right-aligns its menu. --- src/gui/choiceList/AddChoiceControls.svelte | 19 ++++++++++---- tests/e2e/choice-list-layout.test.ts | 28 ++++++++++++++++++++- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/src/gui/choiceList/AddChoiceControls.svelte b/src/gui/choiceList/AddChoiceControls.svelte index 101c4d343..84b3d8ddd 100644 --- a/src/gui/choiceList/AddChoiceControls.svelte +++ b/src/gui/choiceList/AddChoiceControls.svelte @@ -54,20 +54,29 @@ }), ); } - // Anchor the menu under the trigger button. Using the button rect (rather - // than the event coordinates) makes this work for keyboard activation too - // and is robust to Svelte 5's event delegation (currentTarget is the - // delegated root, not the button). // Reflect open state for assistive tech (aria-expanded on the trigger). menuOpen = true; menu.onHide(() => { menuOpen = false; }); + // Anchor the menu under the trigger button. Using the button rect (rather + // than the event coordinates) makes this work for keyboard activation too + // and is robust to Svelte 5's event delegation (currentTarget is the + // delegated root, not the button). Passing the button's width with + // `overlap` keeps the menu under the button when it has to flip, instead + // of ending at the button's left edge. The primary button is the bar's + // rightmost action, so its menu prefers to right-align (`left`). const trigger = (evt.target as HTMLElement | null)?.closest("button"); if (trigger) { const rect = trigger.getBoundingClientRect(); - menu.showAtPosition({ x: rect.left, y: rect.bottom + 4 }); + menu.showAtPosition({ + x: rect.left, + y: rect.bottom + 4, + width: rect.width, + overlap: true, + left: !compact, + }); } else { menu.showAtMouseEvent(evt); } diff --git a/tests/e2e/choice-list-layout.test.ts b/tests/e2e/choice-list-layout.test.ts index 64a7cce12..e14f57382 100644 --- a/tests/e2e/choice-list-layout.test.ts +++ b/tests/e2e/choice-list-layout.test.ts @@ -3,7 +3,7 @@ import { CaptureChoice } from "../../src/types/choices/CaptureChoice"; import { MultiChoice } from "../../src/types/choices/MultiChoice"; import type IChoice from "../../src/types/choices/IChoice"; import { createQuickAddE2EHarness } from "./e2eVault"; -import { waitForElement } from "./uiHelpers"; +import { pressKey, waitForElement } from "./uiHelpers"; const getContext = createQuickAddE2EHarness("choice-list-layout"); @@ -48,3 +48,29 @@ it.each(["is-phone", "is-tablet"])("keeps choice controls compact under %s host await obsidian.dev.evalJson("app.setting.close(); true"); } }); + +it("opens the New choice menu under its button", async () => { + const { obsidian } = getContext(); + try { + await obsidian.dev.evalJson("app.setting.open(); app.setting.openTabById('quickadd'); true"); + await waitForElement(obsidian, ".qaNewChoiceBtn.mod-cta"); + await obsidian.dev.evalJson("document.querySelector('.qaNewChoiceBtn.mod-cta').click(); true"); + await waitForElement(obsidian, ".menu"); + const { button, menu } = await obsidian.dev.evalJson<{ + button: { left: number; right: number; bottom: number }; + menu: { left: number; right: number; top: number }; + }>(`(() => { + const rect = (selector) => { + const { left, right, top, bottom } = document.querySelector(selector).getBoundingClientRect(); + return { left, right, top, bottom }; + }; + return { button: rect('.qaNewChoiceBtn.mod-cta'), menu: rect('.menu') }; + })()`); + expect(menu.top).toBeGreaterThan(button.bottom); + expect(menu.right).toBeCloseTo(button.right, 0); + expect(menu.left).toBeLessThan(button.left); + } finally { + await pressKey(obsidian, "Escape"); + await obsidian.dev.evalJson("app.setting.close(); true"); + } +}); From 750d6d63743ff62e27c4731f54063dff417a383e Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 26 Sep 2026 18:13:39 +0200 Subject: [PATCH 3/3] test(e2e): check FILE links by where they resolve, not their text The written link text follows the vault's link format, so a unique attachment gets [[photo.png]] under the default shortest format and the full-path expectation failed. The spec now resolves each link through the metadata cache and checks it lands on the picked file. --- tests/e2e/file-type-label.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/e2e/file-type-label.test.ts b/tests/e2e/file-type-label.test.ts index d5bc4472d..8156a2e20 100644 --- a/tests/e2e/file-type-label.test.ts +++ b/tests/e2e/file-type-label.test.ts @@ -81,6 +81,13 @@ describe("FILE |type: default label", () => { await pressKey(obsidian, "Enter", true); await expectNoPrompt(obsidian); await expect.poll(() => sandbox.read("out/attachment pair.md").catch(() => ""), POLL_OPTS) - .toBe(`image: [[${folder}/photo.png]]\npdf: [[${folder}/scan.pdf]]\n`); + .toMatch(/^image: \[\[[^\]]+\]\]\npdf: \[\[[^\]]+\]\]\n$/); + // Link text follows the vault's link format, so check where each link lands. + const note = JSON.stringify(sandbox.path("out/attachment pair.md")); + await expect.poll(() => obsidian.dev.evalJson<(string | null)[] | null>(`(() => { + const file = app.vault.getAbstractFileByPath(${note}); + return app.metadataCache.getFileCache(file)?.links?.map(({ link }) => + app.metadataCache.getFirstLinkpathDest(link, ${note})?.path ?? null) ?? null; + })()`), POLL_OPTS).toEqual([`${folder}/photo.png`, `${folder}/scan.pdf`]); }); });